diff --git a/doc/changes/added/14116.md b/doc/changes/added/14116.md new file mode 100644 index 00000000000..9d536663c32 --- /dev/null +++ b/doc/changes/added/14116.md @@ -0,0 +1,6 @@ +- Use `ocamldep` output to filter inter-library dependencies on a + per-module basis. Eliminates unnecessary recompilations when a + dependency library changes but the importing module doesn't + reference it, and, for unwrapped dependency libraries, when the + importing module references a different module of the same + library than the one that changed. (#14116, fixes #4572, @robinbb) diff --git a/src/dune_rules/compilation_context.ml b/src/dune_rules/compilation_context.ml index b629d7874d3..46ba6919ed9 100644 --- a/src/dune_rules/compilation_context.ml +++ b/src/dune_rules/compilation_context.ml @@ -4,55 +4,29 @@ open Memo.O module Includes = struct type t = Command.Args.without_targets Command.Args.t Lib_mode.Cm_kind.Map.t - let make ~project ~opaque ~direct_requires ~hidden_requires lib_config + let make ~project ~direct_requires ~hidden_requires lib_config : _ Lib_mode.Cm_kind.Map.t = - (* TODO: some of the requires can filtered out using [ocamldep] info *) let open Resolve.Memo.O in let iflags direct_libs hidden_libs mode = Lib_flags.L.include_flags ~project ~direct_libs ~hidden_libs mode lib_config in - let make_includes_args ~mode groups = + let make_includes_args ~mode = (let+ direct_libs = direct_requires and+ hidden_libs = hidden_requires in - Command.Args.S - [ iflags direct_libs hidden_libs mode - ; Hidden_deps (Lib_file_deps.deps (direct_libs @ hidden_libs) ~groups) - ]) + iflags direct_libs hidden_libs mode) |> Resolve.Memo.args |> Command.Args.memo in { ocaml = - (let cmi_includes = make_includes_args ~mode:(Ocaml Byte) [ Ocaml Cmi ] in + (let cmi_includes = make_includes_args ~mode:(Ocaml Byte) in { cmi = cmi_includes ; cmo = cmi_includes - ; cmx = - (let+ direct_libs = direct_requires - and+ hidden_libs = hidden_requires in - Command.Args.S - [ iflags direct_libs hidden_libs (Ocaml Native) - ; Hidden_deps - (let libs = direct_libs @ hidden_libs in - if opaque - then - List.map libs ~f:(fun lib -> - ( lib - , if Lib.is_local lib - then [ Lib_file_deps.Group.Ocaml Cmi ] - else [ Ocaml Cmi; Ocaml Cmx ] )) - |> Lib_file_deps.deps_with_exts - else - Lib_file_deps.deps - libs - ~groups:[ Lib_file_deps.Group.Ocaml Cmi; Ocaml Cmx ]) - ]) - |> Resolve.Memo.args - |> Command.Args.memo + ; cmx = make_includes_args ~mode:(Ocaml Native) }) ; melange = - { cmi = make_includes_args ~mode:Melange [ Melange Cmi ] - ; cmj = make_includes_args ~mode:Melange [ Melange Cmi; Melange Cmj ] - } + (let mel_includes = make_includes_args ~mode:Melange in + { cmi = mel_includes; cmj = mel_includes }) } ;; @@ -91,6 +65,8 @@ type t = ; parameters : Module_name.t list Resolve.Memo.t ; instances : Parameterised_instances.t Resolve.Memo.t option ; includes : Includes.t + ; lib_index : Lib_file_deps.Lib_index.t Resolve.t Memo.Lazy.t + ; has_virtual_impl : bool Resolve.t Memo.Lazy.t ; preprocessing : Pp_spec.t ; opaque : bool ; js_of_ocaml : Js_of_ocaml.In_context.t option Js_of_ocaml.Mode.Pair.t @@ -118,6 +94,8 @@ let requires_hidden t = t.requires_hidden let requires_link t = Memo.Lazy.force t.requires_link let parameters t = t.parameters let includes t = t.includes +let lib_index t = Memo.Lazy.force t.lib_index +let has_virtual_impl t = Memo.Lazy.force t.has_virtual_impl let preprocessing t = t.preprocessing let opaque t = t.opaque let js_of_ocaml t = t.js_of_ocaml @@ -147,6 +125,117 @@ let parameters_main_modules parameters = [ "param", Lib.to_dyn param ]) ;; +(* Build a [Lib_index] from the consumer's [direct_requires]. Hidden + requires are intentionally excluded — the user has not committed + to them as direct dependencies, so they fall to glob in the + post-walk classification rather than getting per-module + precision. Local libs short-circuited by [Dep_rules.skip_ocamldep] + (single-module + no resolved deps) land in [no_ocamldep] so the + cross-library walk doesn't read [.d] files dune never produced. *) +let build_lib_index ~super_context ~libs ~for_ = + let open Resolve.Memo.O in + let+ per_lib = + Resolve.Memo.List.map libs ~f:(fun lib -> + match Lib_info.entry_modules (Lib.info lib) ~for_ with + | External (Ok names) -> + Resolve.Memo.return (List.map names ~f:(fun n -> n, lib, None), None, None) + | External (Error e) -> Resolve.Memo.of_result (Error e) + | Local -> + Resolve.Memo.lift_memo + (let open Memo.O in + let* mods = + Dir_contents.modules_of_local_lib + super_context + (Lib.Local.of_lib_exn lib) + ~for_ + in + (* Every local lib's entries carry [Some m] so the walk + can read the entry's ocamldep. Whether to issue per- + module deps is tracked separately via [unwrapped_local] + (tight-eligible = local AND unwrapped). Wrapped local + libs are walkable too via the indexing below, but + remain non-tight (see [lib_file_deps.ml] for the + ocamldep-granularity reason). *) + let unwrapped = + match Lib_info.wrapped (Lib.info lib) with + | Some (This w) -> not (Wrapped.to_bool w) + | Some (From _) | None -> false + in + let entry_modules = Modules.entry_modules mods in + let entry_entries = + List.map entry_modules ~f:(fun m -> Module.name m, lib, Some m) + in + let entries = + if unwrapped + then entry_entries + else ( + let entry_obj_names = + Module_name.Unique.Set.of_list_map entry_modules ~f:Module.obj_name + in + let child_modules = + Modules.fold_user_available mods ~init:[] ~f:(fun m acc -> + if Module_name.Unique.Set.mem entry_obj_names (Module.obj_name m) + then acc + else m :: acc) + in + (* The BFS reaches wrapped libs' children through + whichever of these matches the wrapper kind: + + - Auto-generated wrappers ([Alias]) lack a [.d] + ([Dep_rules.deps_of] short-circuits on [Alias]). + Index every child under the wrapper's name + (multi-entry) so a frontier hit on the wrapper + expands to the children directly. + + - Hand-written wrappers ([Impl]/[Intf_only]) have + a [.d] that names exposed children via the + [module X = X] aliases the user wrote. Index + children under their own names; the BFS + resolves those emitted names in the next round. + Multi-entry-under-wrapper would re-read every + child's [.d] on each wrapper hit, including + children the wrapper doesn't expose. *) + let any_alias_wrapper = + List.exists entry_modules ~f:(fun m -> + match Module.kind m with + | Alias _ -> true + | _ -> false) + in + let child_entries = + if any_alias_wrapper + then + List.concat_map entry_modules ~f:(fun wrapper -> + List.map child_modules ~f:(fun child -> + Module.name wrapper, lib, Some child)) + else + List.map child_modules ~f:(fun child -> + Module.name child, lib, Some child) + in + entry_entries @ child_entries) + in + (* Only single-module libs can land in [no_ocamldep]; + multi-module libs always have [.d] files for their + children. Skip the helper call otherwise. *) + let+ no_ocamldep_lib = + match Modules.as_singleton mods with + | None -> Memo.return None + | Some _ -> + (* Same helper the lib's own cctx uses — keeps the + skip decision and the walk's prediction in lock + step. *) + let+ has_resolved_deps = Dep_rules.has_library_deps_of_lib lib ~for_ in + if has_resolved_deps then None else Some lib + in + entries, no_ocamldep_lib, if unwrapped then Some lib else None)) + in + let entries = List.concat_map per_lib ~f:(fun (e, _, _) -> e) in + let no_ocamldep = List.filter_map per_lib ~f:(fun (_, n, _) -> n) |> Lib.Set.of_list in + let unwrapped_local = + List.filter_map per_lib ~f:(fun (_, _, u) -> u) |> Lib.Set.of_list + in + Lib_file_deps.Lib_index.create ~no_ocamldep ~unwrapped_local ~entries +;; + let create ~super_context ~scope @@ -168,6 +257,7 @@ let create ?cms_cmt_dependency ?loc ?instances + ?own_lib for_ = let project = Scope.project scope in @@ -206,6 +296,18 @@ let create let profile = Context.profile context in eval_opaque ocaml profile opaque in + let* has_library_deps = + (* For library cctxes, route through the same helper + [build_lib_index] uses, so the lib's skip decision and the + cross-stanza walk's prediction can't drift. Other cctxes + just need the boolean. *) + match own_lib with + | Some lib -> Dep_rules.has_library_deps_of_lib lib ~for_ + | None -> + Dep_rules.has_library_deps_of_resolved + ~direct:direct_requires + ~hidden:hidden_requires + in let+ dep_graphs = Dep_rules.rules ~dir:(Obj_dir.dir obj_dir) @@ -215,6 +317,7 @@ let create ~impl:implements ~modules ~for_ + ~has_library_deps and+ bin_annot = match bin_annot with | Some b -> Memo.return b @@ -240,8 +343,18 @@ let create ; requires_link ; implements ; parameters - ; includes = - Includes.make ~project ~opaque ~direct_requires ~hidden_requires ocaml.lib_config + ; includes = Includes.make ~project ~direct_requires ~hidden_requires ocaml.lib_config + ; lib_index = + Memo.lazy_ (fun () -> + let open Resolve.Memo.O in + let* libs = direct_requires in + build_lib_index ~super_context ~libs ~for_) + ; has_virtual_impl = + Memo.lazy_ (fun () -> + let open Resolve.Memo.O in + let+ direct = direct_requires + and+ hidden = hidden_requires in + List.exists (direct @ hidden) ~f:(fun lib -> Option.is_some (Lib.implements lib))) ; preprocessing ; opaque ; js_of_ocaml @@ -333,7 +446,6 @@ let for_module_generated_at_link_time cctx ~requires ~module_ = let direct_requires = requires in Includes.make ~project:(Scope.project cctx.scope) - ~opaque ~direct_requires ~hidden_requires cctx.ocaml.lib_config @@ -344,6 +456,7 @@ let for_module_generated_at_link_time cctx ~requires ~module_ = ; requires_link = Memo.lazy_ (fun () -> requires) ; requires_compile = requires ; includes + ; lib_index = Memo.lazy_ (fun () -> Resolve.Memo.return Lib_file_deps.Lib_index.empty) ; modules } ;; diff --git a/src/dune_rules/compilation_context.mli b/src/dune_rules/compilation_context.mli index f5ac4cae7ca..8f14bd7b24a 100644 --- a/src/dune_rules/compilation_context.mli +++ b/src/dune_rules/compilation_context.mli @@ -40,6 +40,7 @@ val create -> ?cms_cmt_dependency:Workspace.Context.Cms_cmt_dependency.t -> ?loc:Loc.t -> ?instances:Parameterised_instances.t Resolve.Memo.t + -> ?own_lib:Lib.t -> Compilation_mode.t -> t Memo.t @@ -62,6 +63,13 @@ val requires_hidden : t -> Lib.t list Resolve.Memo.t val requires_compile : t -> Lib.t list Resolve.Memo.t val parameters : t -> Module_name.t list Resolve.Memo.t val includes : t -> Command.Args.without_targets Command.Args.t Lib_mode.Cm_kind.Map.t +val lib_index : t -> Lib_file_deps.Lib_index.t Resolve.Memo.t + +(** [true] iff any library in the compilation context's direct or + hidden requires implements a virtual library. Memoized per + cctx; callers avoid re-scanning the lib list on each module. *) +val has_virtual_impl : t -> bool Resolve.Memo.t + val preprocessing : t -> Pp_spec.t val opaque : t -> bool val js_of_ocaml : t -> Js_of_ocaml.In_context.t option Js_of_ocaml.Mode.Pair.t diff --git a/src/dune_rules/dep_graph.ml b/src/dune_rules/dep_graph.ml index 8ff3f084366..dd8b8b2a8cd 100644 --- a/src/dune_rules/dep_graph.ml +++ b/src/dune_rules/dep_graph.ml @@ -7,6 +7,8 @@ type t = } let make ~dir ~per_module = { dir; per_module } +let dir t = t.dir +let mem t (m : Module.t) = Module_name.Unique.Map.mem t.per_module (Module.obj_name m) let deps_of t (m : Module.t) = match Module_name.Unique.Map.find t.per_module (Module.obj_name m) with diff --git a/src/dune_rules/dep_graph.mli b/src/dune_rules/dep_graph.mli index d5f663b222f..d8b5ff3726a 100644 --- a/src/dune_rules/dep_graph.mli +++ b/src/dune_rules/dep_graph.mli @@ -9,6 +9,8 @@ val make -> per_module:Module.t list Action_builder.t Module_name.Unique.Map.t -> t +val dir : t -> Path.Build.t +val mem : t -> Module.t -> bool val deps_of : t -> Module.t -> Module.t list Action_builder.t val top_closed_implementations : t -> Module.t list -> Module.t list Action_builder.t diff --git a/src/dune_rules/dep_rules.ml b/src/dune_rules/dep_rules.ml index 40cbae94f97..12a61897db2 100644 --- a/src/dune_rules/dep_rules.ml +++ b/src/dune_rules/dep_rules.ml @@ -131,9 +131,58 @@ let deps_of_vlib_module ~obj_dir ~vimpl ~dir ~sctx ~ml_kind ~for_ sourced_module Ocamldep.read_deps_of ~obj_dir:vlib_obj_dir ~modules ~ml_kind ~for_ m ;; -(** Tests whether a set of modules is a singleton *) +(** Tests whether a set of modules is a singleton. *) let has_single_file modules = Option.is_some @@ Modules.With_vlib.as_singleton modules +(* On resolution failure both [has_library_deps_*] helpers below + return [true]. Conservative-true keeps [.d]-file rules in place, + so the unresolved-library error surfaces as a real compile-rule + diagnostic. Conservative-false would short-circuit the [.d] + rules and turn that diagnostic into a "No rule found for + .X.objs/X.impl.d" infrastructure error. The trade-off is + asymmetric: forced choice. *) + +(** Canonical [has_library_deps] for [lib]'s own cctx. The cross- + library walk in [Compilation_context.build_lib_index] consults + the same helper, so the skip decision and the walk's prediction + can't drift. *) +let has_library_deps_of_lib lib ~for_ = + let open Memo.O in + let+ resolved = Lib.requires lib ~for_ |> Memo.map ~f:Resolve.peek in + match resolved with + | Ok [] -> false + | Ok _ | Error _ -> true +;; + +(** Same boolean as [has_library_deps_of_lib], for cctxes without a + [Lib.t] (executables, tests, melange emit). Takes the resolved + [direct] and [hidden] requires already split by + [Compilation_context.create] per [implicit_transitive_deps] + mode. *) +let has_library_deps_of_resolved ~direct ~hidden = + let resolved = + let open Resolve.Memo.O in + let+ direct = direct + and+ hidden = hidden in + match direct, hidden with + | [], [] -> false + | _ -> true + in + let open Memo.O in + let+ peeked = Memo.map resolved ~f:Resolve.peek in + Result.value peeked ~default:true +;; + +(** Single-module stanzas with no library deps don't run ocamldep. + Under #4572, a library short-circuited here must also appear in + [Compilation_context.build_lib_index]'s [no_ocamldep] set or + the cross-library walk will read a [.d] dune never produced. + Both sides derive their [has_library_deps] via the helpers + above. *) +let skip_ocamldep ~has_library_deps modules = + has_single_file modules && not has_library_deps +;; + let rec deps_of ~obj_dir ~modules @@ -143,6 +192,7 @@ let rec deps_of ~sctx ~ml_kind ~for_ + ~has_library_deps (m : Modules.Sourced_module.t) = let is_alias_or_root = @@ -153,7 +203,7 @@ let rec deps_of | Root | Alias _ -> true | _ -> false) in - if is_alias_or_root || has_single_file modules + if is_alias_or_root || skip_ocamldep ~has_library_deps modules then Memo.return (Action_builder.return []) else ( let skip_if_source_absent f sourced_module = @@ -173,7 +223,7 @@ let rec deps_of (deps_of_module ~modules ~sandbox ~sctx ~dir ~obj_dir ~ml_kind ~for_) m | Impl_of_virtual_module impl_or_vlib -> - deps_of ~obj_dir ~modules ~sandbox ~impl ~dir ~sctx ~ml_kind ~for_ + deps_of ~obj_dir ~modules ~sandbox ~impl ~dir ~sctx ~ml_kind ~for_ ~has_library_deps @@ let m = Ml_kind.Dict.get impl_or_vlib ml_kind in (match ml_kind with @@ -181,6 +231,10 @@ let rec deps_of | Impl -> Normal m)) ;; +(* [read_deps_of_module] reports intra-stanza module dependencies. For + single-module stanzas that dependency graph is trivially empty + regardless of whether the stanza declares library dependencies, so we + keep the unconditional short-circuit here. *) let read_deps_of_module ~modules ~obj_dir dep ~for_ = let (Obj_dir.Module.Dep.Immediate (unit, _) | Transitive (unit, _)) = dep in match Module.kind unit with @@ -219,18 +273,37 @@ let dict_of_func_concurrently f = let for_module ~obj_dir ~modules ~sandbox ~impl ~dir ~sctx ~for_ module_ = dict_of_func_concurrently - (deps_of ~obj_dir ~modules ~sandbox ~impl ~dir ~sctx ~for_ (Normal module_)) + (deps_of + ~obj_dir + ~modules + ~sandbox + ~impl + ~dir + ~sctx + ~for_ + ~has_library_deps:true + (Normal module_)) ;; -let rules ~obj_dir ~modules ~sandbox ~impl ~sctx ~dir ~for_ = +let rules ~obj_dir ~modules ~sandbox ~impl ~sctx ~dir ~for_ ~has_library_deps = match Modules.With_vlib.as_singleton modules with - | Some m -> Memo.return (Dep_graph.Ml_kind.dummy m) - | None -> + | Some m when not has_library_deps -> Memo.return (Dep_graph.Ml_kind.dummy m) + | Some _ | None -> dict_of_func_concurrently (fun ~ml_kind -> let+ per_module = Modules.With_vlib.obj_map modules |> Parallel_map.parallel_map ~f:(fun _obj_name m -> - deps_of ~obj_dir ~modules ~sandbox ~impl ~sctx ~dir ~ml_kind ~for_ m) + deps_of + ~obj_dir + ~modules + ~sandbox + ~impl + ~sctx + ~dir + ~ml_kind + ~for_ + ~has_library_deps + m) in Dep_graph.make ~dir ~per_module) |> Memo.map ~f:(Dep_graph.Ml_kind.for_module_compilation ~modules) diff --git a/src/dune_rules/dep_rules.mli b/src/dune_rules/dep_rules.mli index 9907e345b12..fe9ecc26a14 100644 --- a/src/dune_rules/dep_rules.mli +++ b/src/dune_rules/dep_rules.mli @@ -13,6 +13,12 @@ val for_module -> Module.t -> Module.t list Action_builder.t Ml_kind.Dict.t Memo.t +(** [has_library_deps] indicates whether the enclosing stanza declares any + library dependencies. When false, single-module stanzas short-circuit + ocamldep entirely (no build rule, no [.d]/[.all-deps] file). When + true, single-module stanzas run ocamldep so that the per-module + inter-library dependency filter can determine which libraries the + single module references. *) val rules : obj_dir:Path.Build.t Obj_dir.t -> modules:Modules.With_vlib.t @@ -21,8 +27,29 @@ val rules -> sctx:Super_context.t -> dir:Path.Build.t -> for_:Compilation_mode.t + -> has_library_deps:bool -> Dep_graph.Ml_kind.t Memo.t +(** Canonical [has_library_deps] for a library, used both by + [Compilation_context.create] (when building a library cctx, to + derive the [has_library_deps] passed to [rules]) and by + [Compilation_context.build_lib_index] (when predicting whether + the lib's [.d] file will exist for the cross-library walk). + Returns [true] if [Lib.requires lib ~for_] resolves to a + non-empty list, or if resolution fails (conservative — see the + rationale comment in [dep_rules.ml]). *) +val has_library_deps_of_lib : Lib.t -> for_:Compilation_mode.t -> bool Memo.t + +(** [has_library_deps] for non-library cctxes (executables, tests, + melange emit), derived from already-resolved [direct] and + [hidden] requires. Resolution failures conservatively fall + back to has-deps=true — see the rationale comment in + [dep_rules.ml]. *) +val has_library_deps_of_resolved + : direct:Lib.t list Resolve.Memo.t + -> hidden:Lib.t list Resolve.Memo.t + -> bool Memo.t + val read_immediate_deps_of : obj_dir:Path.Build.t Obj_dir.t -> modules:Modules.With_vlib.t diff --git a/src/dune_rules/lib.ml b/src/dune_rules/lib.ml index a8630f413db..4a0a76faa7e 100644 --- a/src/dune_rules/lib.ml +++ b/src/dune_rules/lib.ml @@ -2198,11 +2198,51 @@ end = struct ;; end -let closure l ~linking = - let forbidden_libraries = Map.empty in - if linking - then Resolve_names.linking_closure_with_overlap_checks None l ~forbidden_libraries - else Resolve_names.compile_closure_with_overlap_checks None l ~forbidden_libraries +let closure = + let memo = + let module Input = struct + type nonrec t = bool * Compilation_mode.t * t list + + let equal (l, m, libs) (l', m', libs') = + Bool.equal l l' && Compilation_mode.equal m m' && List.equal equal libs libs' + ;; + + let hash_for_ = function + | Compilation_mode.Ocaml -> 0 + | Melange -> 1 + ;; + + let hash (linking, for_, libs) = + Tuple.T3.hash + Bool.hash + hash_for_ + (List.hash (fun lib -> Id.hash lib.unique_id)) + (linking, for_, libs) + ;; + + let to_dyn = Dyn.opaque + end + in + Memo.create + "lib-closure" + ~input:(module Input) + (fun (linking, for_, l) -> + let forbidden_libraries = Map.empty in + if linking + then + Resolve_names.linking_closure_with_overlap_checks + None + l + ~forbidden_libraries + ~for_ + else + Resolve_names.compile_closure_with_overlap_checks + None + l + ~forbidden_libraries + ~for_) + in + fun l ~linking ~for_ -> Memo.exec memo (linking, for_, l) ;; let descriptive_closure (l : lib list) ~with_pps ~for_ : lib list Memo.t = diff --git a/src/dune_rules/lib.mli b/src/dune_rules/lib.mli index 1ab86f84d21..0d9fd7f99df 100644 --- a/src/dune_rules/lib.mli +++ b/src/dune_rules/lib.mli @@ -217,6 +217,10 @@ end (** {1 Transitive closure} *) +(** Memoized. The memo key is order-sensitive on the input list, so + for callers that share inputs across invocations (e.g. the hot + path in [Module_compilation.lib_deps_for_module]), a canonical + sort (by [Lib.compare]) maximises cache reuse. *) val closure : t list -> linking:bool -> for_:Compilation_mode.t -> t list Resolve.Memo.t (** [descriptive_closure ~with_pps libs] computes the smallest set of libraries diff --git a/src/dune_rules/lib_file_deps.ml b/src/dune_rules/lib_file_deps.ml index 0aa3440da59..f71b3dc26a6 100644 --- a/src/dune_rules/lib_file_deps.ml +++ b/src/dune_rules/lib_file_deps.ml @@ -1,6 +1,14 @@ open Import open Memo.O +(* Wrapped libraries fall back to a directory glob because + [ocamldep -modules foo.ml] reports only top-level module names: + a consumer using [Foo.Bar.x] yields [Foo], indistinguishable + from one using [Foo.Baz.x]. Per-module precision on + [Foo__Bar.cmi] vs [Foo__Baz.cmi] isn't recoverable from + ocamldep output alone, so the BFS reach over the wrapper is + equivalent to a glob for invalidation. *) + module Group = struct type ocaml = | Cmi @@ -48,9 +56,146 @@ let deps_of_lib (lib : Lib.t) ~groups = |> Dep.Set.of_list ;; -let deps_with_exts = Dep.Set.union_map ~f:(fun (lib, groups) -> deps_of_lib lib ~groups) let deps libs ~groups = Dep.Set.union_map libs ~f:(deps_of_lib ~groups) +let groups_for_cm_kind ~opaque ~(cm_kind : Lib_mode.Cm_kind.t) lib = + match cm_kind with + | Ocaml Cmi | Ocaml Cmo -> [ Group.Ocaml Cmi ] + | Ocaml Cmx -> + if opaque && Lib.is_local lib + then [ Group.Ocaml Cmi ] + else [ Group.Ocaml Cmi; Group.Ocaml Cmx ] + | Melange Cmi -> [ Group.Melange Cmi ] + | Melange Cmj -> [ Group.Melange Cmi; Group.Melange Cmj ] +;; + +let deps_of_entries ~opaque ~cm_kind libs = + Dep.Set.union_map libs ~f:(fun lib -> + deps_of_lib lib ~groups:(groups_for_cm_kind ~opaque ~cm_kind lib)) +;; + +(* [cm_public_file] gives the cmi path consumers read via their [-I] + include path, which for libraries with a dedicated public cmi dir + ([private_modules]) differs from the internal compilation output. + Using it ensures the dep triggers the produce-public-cmi rule. + + Only called for local unwrapped libraries: [can_filter] rejects + melange cm_kinds, so this function handles ocaml only. *) +let deps_of_entry_modules ~opaque ~(cm_kind : Lib_mode.Cm_kind.t) lib modules = + let obj_dir = Lib.info lib |> Lib_info.obj_dir in + let cmi_kind = Lib_mode.Cm_kind.cmi cm_kind in + let want_cmx = + match cm_kind with + | Ocaml Cmx -> not (opaque && Lib.is_local lib) + | _ -> false + in + List.fold_left modules ~init:Dep.Set.empty ~f:(fun acc m -> + let acc = + match Obj_dir.Module.cm_public_file obj_dir m ~kind:cmi_kind with + | Some path -> Dep.Set.add acc (Dep.file path) + | None -> acc + in + if want_cmx && Module.has m ~ml_kind:Impl + then ( + match Obj_dir.Module.cm_public_file obj_dir m ~kind:(Ocaml Cmx) with + | Some path -> Dep.Set.add acc (Dep.file path) + | None -> acc) + else acc) +;; + +module Lib_index = struct + type t = + { by_module_name : (Lib.t * Module.t option) list Module_name.Map.t + ; tight_eligible : Lib.Set.t + ; no_ocamldep : Lib.Set.t + (* Local libs whose ocamldep is short-circuited by + [Dep_rules.skip_ocamldep] — single-module stanzas without + library dependencies have no [.d] build rules. The + cross-library walk must not try to read ocamldep for them; + their entry module can have no cross-library references + anyway. *) + } + + let empty = + { by_module_name = Module_name.Map.empty + ; tight_eligible = Lib.Set.empty + ; no_ocamldep = Lib.Set.empty + } + ;; + + (* [Some m] in an entry (true for any local lib, false for + externals) is what lets the BFS read the entry's ocamldep. + [tight_eligible] (= local AND unwrapped, supplied by the + caller) is the orthogonal flag controlling per-module + precision: wrapped libs are walkable but glob-only for + invalidation (see file-level comment for the + ocamldep-granularity reason). *) + let create ~no_ocamldep ~unwrapped_local ~entries = + let by_module_name = + List.fold_left entries ~init:Module_name.Map.empty ~f:(fun map (name, lib, m) -> + Module_name.Map.update map name ~f:(function + | None -> Some [ lib, m ] + | Some xs -> Some ((lib, m) :: xs))) + in + { by_module_name; tight_eligible = unwrapped_local; no_ocamldep } + ;; + + type classified = + { tight : Module.t list Lib.Map.t + ; non_tight : Lib.t list + } + + let filter_libs_with_modules idx ~referenced_modules = + let add_entry (tight, non_tight) (lib, m_opt) = + match m_opt with + | Some m when Lib.Set.mem idx.tight_eligible lib -> + let tight = + Lib.Map.update tight lib ~f:(function + | None -> Some [ m ] + | Some ms -> Some (m :: ms)) + in + tight, non_tight + | _ -> tight, Lib.Set.add non_tight lib + in + let tight, non_tight = + Module_name.Set.fold + referenced_modules + ~init:(Lib.Map.empty, Lib.Set.empty) + ~f:(fun name acc -> + match Module_name.Map.find idx.by_module_name name with + | None -> acc + | Some entries -> List.fold_left entries ~init:acc ~f:add_entry) + in + { tight; non_tight = Lib.Set.to_list non_tight } + ;; + + let tight_modules_per_lib idx ~referenced_modules = + Module_name.Set.fold referenced_modules ~init:Lib.Map.empty ~f:(fun name acc -> + match Module_name.Map.find idx.by_module_name name with + | None -> acc + | Some entries -> + List.fold_left entries ~init:acc ~f:(fun acc (lib, m_opt) -> + match m_opt with + | Some m when Lib.Set.mem idx.tight_eligible lib -> + Lib.Map.update acc lib ~f:(function + | None -> Some [ m ] + | Some ms -> Some (m :: ms)) + | _ -> acc)) + ;; + + let lookup_walkable_entries idx name = + match Module_name.Map.find idx.by_module_name name with + | None -> [] + | Some entries -> + List.filter_map entries ~f:(fun (lib, m_opt) -> + match m_opt with + | Some m when not (Lib.Set.mem idx.no_ocamldep lib) -> Some (lib, m) + | _ -> None) + ;; + + let is_tight_eligible idx lib = Lib.Set.mem idx.tight_eligible lib +end + type path_specification = | Allow_all | Disallow_external of Lib_name.t diff --git a/src/dune_rules/lib_file_deps.mli b/src/dune_rules/lib_file_deps.mli index 75a3453e64b..9cff4c8ac93 100644 --- a/src/dune_rules/lib_file_deps.mli +++ b/src/dune_rules/lib_file_deps.mli @@ -15,7 +15,72 @@ end with extension [files] of libraries [libs]. *) val deps : Lib.t list -> groups:Group.t list -> Dep.Set.t -val deps_with_exts : (Lib.t * Group.t list) list -> Dep.Set.t +(** [deps_of_entries ~opaque ~cm_kind libs] computes the file dependencies + (glob deps on .cmi/.cmx files) for the given libraries. *) +val deps_of_entries : opaque:bool -> cm_kind:Lib_mode.Cm_kind.t -> Lib.t list -> Dep.Set.t + +(** [deps_of_entry_modules ~opaque ~cm_kind lib modules] computes the + file dependencies on the specific [modules] of [lib], using + [Obj_dir.Module.cm_file_exn] for path construction. Only valid for + local libraries (for which [Module.t] is available). *) +val deps_of_entry_modules + : opaque:bool + -> cm_kind:Lib_mode.Cm_kind.t + -> Lib.t + -> Module.t list + -> Dep.Set.t + +module Lib_index : sig + type t + + val empty : t + + (** [unwrapped_local] is the tight-eligible set (local AND + unwrapped); [no_ocamldep] names local libs whose [.d] files + are short-circuited away by [Dep_rules.skip_ocamldep] and + must therefore be excluded from the cross-library walk. + [entries] carries [Some m] for local libs, [None] for + externals — see [Compilation_context.build_lib_index] for the + indexing convention used for wrapped libs' children. *) + val create + : no_ocamldep:Lib.Set.t + -> unwrapped_local:Lib.Set.t + -> entries:(Module_name.t * Lib.t * Module.t option) list + -> t + + type classified = + { tight : Module.t list Lib.Map.t + (** Tight-eligible libs the consumer references, mapped to + the referenced entry modules. *) + ; non_tight : Lib.t list + (** Other referenced libs (wrapped locals, externals, …), + sorted by [Lib.compare]. *) + } + + (** Classify the libs whose entry modules appear in + [referenced_modules]. *) + val filter_libs_with_modules : t -> referenced_modules:Module_name.Set.t -> classified + + (** Like [filter_libs_with_modules] but returns only the [tight] + map. *) + val tight_modules_per_lib + : t + -> referenced_modules:Module_name.Set.t + -> Module.t list Lib.Map.t + + (** Walkable entries indexed under [name]: wrappers and children + of wrapped libs alike (see [Compilation_context.build_lib_ + index] for the indexing convention). Libs in [no_ocamldep] + and externals are excluded. *) + val lookup_walkable_entries : t -> Module_name.t -> (Lib.t * Module.t) list + + (** A library is tight-eligible when local and unwrapped. The + walk has full visibility into such libs, so absence of all + their entry modules from the post-walk reference set means + the consumer doesn't reach the lib; the compile rule needs + no dep on it. *) + val is_tight_eligible : t -> Lib.t -> bool +end type path_specification = | Allow_all diff --git a/src/dune_rules/lib_rules.ml b/src/dune_rules/lib_rules.ml index 5a8c0ca3ae7..1b25f236a44 100644 --- a/src/dune_rules/lib_rules.ml +++ b/src/dune_rules/lib_rules.ml @@ -476,6 +476,7 @@ let cctx ~scope ~parameters ~compile_info + ~local_lib ~modes ~for_ = @@ -532,6 +533,7 @@ let cctx ~melange_package_name ~modes ~instances + ~own_lib:local_lib ;; let library_rules @@ -675,7 +677,7 @@ let compile_context_data (lib : Library.t) ~sctx ~dir_contents ~scope = let compile_context (lib : Library.t) ~sctx ~dir_contents ~expander ~scope = let dir = Dir_contents.dir dir_contents in - let* _, compile_info, modes, source_modules, parameters = + let* local_lib, compile_info, modes, source_modules, parameters = compile_context_data lib ~sctx ~dir_contents ~scope in cctx @@ -687,6 +689,7 @@ let compile_context (lib : Library.t) ~sctx ~dir_contents ~expander ~scope = ~expander ~parameters ~compile_info + ~local_lib ~modes ~for_:Ocaml ;; @@ -708,6 +711,7 @@ let rules (lib : Library.t) ~sctx ~dir_contents ~expander ~scope = ~expander ~parameters ~compile_info + ~local_lib ~modes ~for_:Ocaml in diff --git a/src/dune_rules/module_compilation.ml b/src/dune_rules/module_compilation.ml index 75bcea17c87..9a6909c3299 100644 --- a/src/dune_rules/module_compilation.ml +++ b/src/dune_rules/module_compilation.ml @@ -1,6 +1,245 @@ open Import open Memo.O +(* The compile rule depends on two disjoint dep sets: [other_cm_files] + for intra-library cmis (static, from [Dep_graph.t]) and + [lib_cm_deps] for inter-library deps (dynamic, the per-module + filter from #4572). Disjointness comes from [parse_module_names] + stripping intra-lib references on the intra-lib path while the + inter-lib path keeps raw [Module_name.t] values. *) + +let all_libs cctx = + let open Resolve.Memo.O in + let+ d = Compilation_context.requires_compile cctx + and+ h = Compilation_context.requires_hidden cctx in + d @ h +;; + +let union_module_name_sets_mapped xs ~f = + Action_builder.List.map xs ~f + |> Action_builder.map + ~f:(List.fold_left ~init:Module_name.Set.empty ~f:Module_name.Set.union) +;; + +(* A module is filterable (per-module lib deps apply) only when it has a + proper source-level kind: user [Impl]/[Intf_only] or a transparent + alias whose ocamldep we can read to follow the chain. *) +let module_kind_is_filterable m = + match Module.kind m with + | Root | Wrapped_compat | Impl_vmodule | Virtual | Parameter -> false + | Intf_only | Impl | Alias _ -> true +;; + +(* Extend [initial_refs] with module names reached through cross- + library ocamldep. The frontier expansion follows alias chains + into wrapped libs' children; how those children land in the + index is the indexing decision documented in + [Compilation_context.build_lib_index]. Externals and libs in + [no_ocamldep] are absent from [lookup_walkable_entries], so + chains through them terminate and the consumer falls back to a + glob on the unreached libs. + + Rule-graph cycle safety: [.d] files depend only on their source + [.ml]/[.mli] (via [Ocamldep.deps_of]), never on any cmi, so + reading a cross-library [.d] file cannot transitively demand any + consumer output. The walk cannot introduce rule cycles regardless + of the library graph. *) +let cross_lib_tight_set ~lib_index ~for_ ~initial_refs = + let open Action_builder.O in + let read_entry_deps (lib, m) = + let obj_dir = Lib.info lib |> Lib_info.obj_dir |> Obj_dir.as_local_exn in + let* impl_deps = Ocamldep.read_immediate_deps_raw_of ~obj_dir ~ml_kind:Impl ~for_ m in + let+ intf_deps = Ocamldep.read_immediate_deps_raw_of ~obj_dir ~ml_kind:Intf ~for_ m in + Module_name.Set.union impl_deps intf_deps + in + let rec loop ~seen ~frontier = + if Module_name.Set.is_empty frontier + then Action_builder.return seen + else ( + let pairs = + Module_name.Set.fold frontier ~init:[] ~f:(fun name acc -> + Lib_file_deps.Lib_index.lookup_walkable_entries lib_index name @ acc) + in + let* discovered = union_module_name_sets_mapped pairs ~f:read_entry_deps in + let seen = Module_name.Set.union seen frontier in + let frontier = Module_name.Set.diff discovered seen in + loop ~seen ~frontier) + in + loop ~seen:Module_name.Set.empty ~frontier:initial_refs +;; + +(* Per-module inter-library dependency filtering (#4572). The + returned [Dep.Set.t] is the sole source of cross-library rule + deps for the compile rule — [-I] flags add search paths but + register no deps — so narrowing here directly narrows rule + invalidation. Falls back to a glob over the library's objdir + when filtering is unsafe (Melange, virtual-impl) or unavailable + (wrapped libs; see [lib_file_deps.ml]'s opening for the + ocamldep-granularity reason). *) +let lib_deps_for_module ~cctx ~obj_dir ~for_ ~dep_graph ~opaque ~cm_kind ~ml_kind ~mode m = + let open Action_builder.O in + let can_filter = + (* Filtering is OCaml-only; Melange has its own dep handling. *) + (match Lib_mode.of_cm_kind cm_kind with + | Melange -> false + | Ocaml _ -> true) + (* [Dep_graph.dir = Path.Build.root] indicates a dummy dep graph + from [Compilation_context.for_alias_module], + [for_root_module], or [for_module_generated_at_link_time]. + Those contexts replace [modules] with [singleton_modules]; + the resulting dummy dep graph cannot supply transitive + deps. *) + && Path.Build.equal (Dep_graph.dir dep_graph) (Obj_dir.dir obj_dir) + (* Reached when [m] was synthesised outside the stanza's module + set and handed directly to [ocamlc_i] — menhir's [mock_module] + is the concrete case. *) + && Dep_graph.mem dep_graph m + && module_kind_is_filterable m + && Module.has m ~ml_kind + (* Consumer-side virtual-impl check: this stanza is itself a + virtual-library implementation, which has its own dep + machinery (see [Dep_rules.deps_of_vlib_module]). The + complementary check below ([has_virtual_impl]) covers the + case where the consumer's dep list contains a virtual + implementation. *) + && not (Virtual_rules.is_implementation (Compilation_context.implements cctx)) + in + let* libs = Resolve.Memo.read (all_libs cctx) in + if not can_filter + then Action_builder.return (Lib_file_deps.deps_of_entries ~opaque ~cm_kind libs) + else + (* Deps-side virtual-impl check: if any library in the consumer's + requires implements a virtual library, fall back to glob. + Distinct from the consumer-stanza check in [can_filter]. *) + let* has_virtual_impl = + Resolve.Memo.read (Compilation_context.has_virtual_impl cctx) + in + if has_virtual_impl + then Action_builder.return (Lib_file_deps.deps_of_entries ~opaque ~cm_kind libs) + else + let* lib_index = Resolve.Memo.read (Compilation_context.lib_index cctx) in + let* trans_deps = Dep_graph.deps_of dep_graph m in + (* Whether to read [dep_m]'s [.ml]-side ocamldep. [.ml]-side + references propagate to the consumer only when [dep_m]'s + [.cmx] is read ([Cmx] + not opaque) or when [dep_m] has no + [.mli] (so its [.cmi] is produced from [.ml]). For the + consumer itself, [.ml] is read iff we are compiling its + [.ml] (i.e. [ml_kind = Impl]). + + | [dep_m] is | [cm_kind] | [opaque] | read [.ml]? | + | ----------------------- | ----------- | -------- | ------------ | + | consumer ([m] itself) | any | any | iff [Impl] | + | trans_dep, no [.mli] | any | any | yes | + | trans_dep, has [.mli] | [Cmx] | false | yes (inline) | + | trans_dep, has [.mli] | [Cmx] | true | no | + | trans_dep, has [.mli] | [Cmi]/[Cmo] | any | no | + *) + let need_impl_deps_of dep_m ~is_consumer = + if is_consumer + then ( + match ml_kind with + | Ml_kind.Impl -> true + | Intf -> false) + else + (not (Module.has dep_m ~ml_kind:Intf)) + || + match cm_kind with + | Ocaml Cmx -> not opaque + | Ocaml (Cmi | Cmo) | Melange _ -> false + in + let read_dep_m_raw dep_m ~is_consumer = + let* impl_deps = + if need_impl_deps_of dep_m ~is_consumer + then Ocamldep.read_immediate_deps_raw_of ~obj_dir ~ml_kind:Impl ~for_ dep_m + else Action_builder.return Module_name.Set.empty + in + let+ intf_deps = + Ocamldep.read_immediate_deps_raw_of ~obj_dir ~ml_kind:Intf ~for_ dep_m + in + Module_name.Set.union impl_deps intf_deps + in + let* m_raw = read_dep_m_raw m ~is_consumer:true in + let* trans_raw = + union_module_name_sets_mapped trans_deps ~f:(read_dep_m_raw ~is_consumer:false) + in + let all_raw = Module_name.Set.union m_raw trans_raw in + let* flags = Ocaml_flags.get (Compilation_context.flags cctx) mode in + let open_modules = Ocaml_flags.extract_open_module_names flags in + let referenced = Module_name.Set.union all_raw open_modules in + (* Seed [Lib.closure] with the libs the consumer directly + names; the post-BFS classify below uses + [tight_modules_per_lib] on the extended name set. *) + let { Lib_file_deps.Lib_index.tight; non_tight } = + Lib_file_deps.Lib_index.filter_libs_with_modules + lib_index + ~referenced_modules:referenced + in + (* Sort for [Lib.closure] memo-key stability. *) + let direct_libs = List.sort ~compare:Lib.compare (Lib.Map.keys tight @ non_tight) in + (* Close transitively to catch cross-library [.cmi] reads via + transparent aliases that ocamldep doesn't report. + [Lib.closure] expands link-time deps too; under + [(implicit_transitive_deps false)] those reach libs that + the compiler cannot see (no entry in [-I] or [-H]). The + fold below filters them out via [compile_scope]. *) + let* all_libs = Resolve.Memo.read (Lib.closure direct_libs ~linking:false ~for_) in + let+ tight_set = cross_lib_tight_set ~lib_index ~for_ ~initial_refs:referenced in + let compile_scope = Lib.Set.of_list libs in + (* Classify each lib in [all_libs]: + - in [tight_modules]: per-module deps. + - tight-eligible but unreferenced: drop. Link still pulls + it in via [requires_link]. + - in [compile_scope] but not tight-eligible: glob (wrapped, + external, virtual-impl, or hidden under + [Disabled_with_hidden_includes]). + - outside [compile_scope]: drop. Link-only transitive dep + that the compiler cannot reference. *) + let tight_modules = + Lib_file_deps.Lib_index.tight_modules_per_lib + lib_index + ~referenced_modules:tight_set + in + let tight_deps, glob_libs = + List.fold_left all_libs ~init:(Dep.Set.empty, []) ~f:(fun (td, gl) lib -> + match Lib.Map.find tight_modules lib with + | Some modules -> + ( Dep.Set.union + td + (Lib_file_deps.deps_of_entry_modules ~opaque ~cm_kind lib modules) + , gl ) + | None -> + if Lib_file_deps.Lib_index.is_tight_eligible lib_index lib + then td, gl + else if Lib.Set.mem compile_scope lib + then td, lib :: gl + else td, gl) + in + let glob_deps = Lib_file_deps.deps_of_entries ~opaque ~cm_kind glob_libs in + Dep.Set.union tight_deps glob_deps +;; + +let lib_cm_deps ~cctx ~cm_kind ~ml_kind ~mode m = + let obj_dir = Compilation_context.obj_dir cctx in + let opaque = Compilation_context.opaque cctx in + let for_ = Compilation_context.for_ cctx in + let dep_graph = Ml_kind.Dict.get (Compilation_context.dep_graphs cctx) ml_kind in + let open Action_builder.O in + Action_builder.dyn_deps + (let+ deps = + lib_deps_for_module + ~cctx + ~obj_dir + ~for_ + ~dep_graph + ~opaque + ~cm_kind + ~ml_kind + ~mode + m + in + (), deps) +;; + (* Arguments for the compiler to prevent it from being too clever. The compiler creates the cmi when it thinks a .ml file has no corresponding @@ -286,6 +525,18 @@ let build_cm | Some All | None -> Hidden_targets [ obj ]) in let opaque = Compilation_context.opaque cctx in + let skip_lib_deps = + match Module.kind m with + | Alias _ -> + not (Modules.With_vlib.is_stdlib_alias (Compilation_context.modules cctx) m) + | Wrapped_compat -> true + | Intf_only | Virtual | Impl | Impl_vmodule | Root | Parameter -> false + in + let lib_cm_deps = + if skip_lib_deps + then Action_builder.return () + else lib_cm_deps ~cctx ~cm_kind ~ml_kind ~mode m + in let other_cm_files = let dep_graph = Ml_kind.Dict.get (Compilation_context.dep_graphs cctx) ml_kind in let module_deps = Dep_graph.deps_of dep_graph m in @@ -404,6 +655,7 @@ let build_cm ?loc:(Compilation_context.loc cctx) (let open Action_builder.With_targets.O in Action_builder.with_no_targets other_cm_files + >>> Action_builder.with_no_targets lib_cm_deps >>> Command.run ~dir:(Path.build (Context.build_dir ctx)) compiler @@ -519,6 +771,9 @@ let ocamlc_i ~deps cctx (m : Module.t) ~output = List.concat_map deps ~f:(fun m -> [ Path.build (Obj_dir.Module.cm_file_exn obj_dir m ~kind:(Ocaml Cmi)) ])) in + let lib_cm_deps = + lib_cm_deps ~cctx ~cm_kind:(Ocaml Cmo) ~ml_kind:Impl ~mode:(Ocaml Byte) m + in let ocaml_flags = Ocaml_flags.get (Compilation_context.flags cctx) (Ocaml Byte) in let modules = Compilation_context.modules cctx in let ocaml = Compilation_context.ocaml cctx in @@ -529,6 +784,7 @@ let ocamlc_i ~deps cctx (m : Module.t) ~output = ~file_targets:[ output ] (let open Action_builder.With_targets.O in Action_builder.with_no_targets cm_deps + >>> Action_builder.with_no_targets lib_cm_deps >>> Command.run (Ok ocaml.ocamlc) ~dir:(Path.build (Context.build_dir ctx)) @@ -677,6 +933,33 @@ let build_root_module cctx root_module = let+ entries = entries in root_source entries)) in + (* Write a synthesized immediate-deps file in the same format + [ocamldep -modules] would produce. The content is exactly + what running ocamldep on the generated [root.ml] would + output: the entry-module names of every required library. + [Dep_rules.deps_of]'s [is_alias_or_root] short-circuit + remains intact, so no transitive [.all-deps] file is + produced for [Root] — preserving the cycle prevention + introduced in commit a5d894525 (change #12227). The cycle + lives in [.all-deps] generation, not in [.d] reading. *) + let* () = + let obj_dir = Compilation_context.obj_dir cctx in + match Obj_dir.Module.dep obj_dir ~for_ (Immediate (root_module, Ml_kind.Impl)) with + | None -> Memo.return () + | Some d_target -> + Super_context.add_rule + ~loc:Loc.none + sctx + ~dir + (Action_builder.write_file_dyn + d_target + (let open Action_builder.O in + let+ entries = entries in + sprintf + "%s: %s\n" + (Path.Build.basename (Path.as_in_build_dir_exn file)) + (String.concat ~sep:" " (List.map entries ~f:Module_name.to_string)))) + in build_module cctx root_module ;; diff --git a/src/dune_rules/modules.ml b/src/dune_rules/modules.ml index 6a42fed4e9a..6d6ad4fd25d 100644 --- a/src/dune_rules/modules.ml +++ b/src/dune_rules/modules.ml @@ -670,6 +670,12 @@ module Wrapped = struct let lib_interface t = Group.lib_interface t.group + (* Entry modules visible to consumers of a wrapped library: the wrapper + itself, plus any [wrapped_compat] shims (present for + [(wrapped (transition ...))] libraries, which expose bare module names + in addition to qualified ones). *) + let entry_modules t = lib_interface t :: Module_name.Map.values t.wrapped_compat + let fold_user_available { group; toplevel_module; _ } ~init ~f = let init = match toplevel_module with @@ -918,6 +924,12 @@ let wrapped t = | Stdlib _ -> Simple true ;; +let as_singleton t = + match t.modules with + | Singleton m -> Some m + | Unwrapped _ | Wrapped _ | Stdlib _ -> None +;; + let is_user_written m = match Module.kind m with | Root | Wrapped_compat | Alias _ -> false @@ -1005,7 +1017,7 @@ let entry_modules t = | Unwrapped m -> Unwrapped.entry_modules m | Wrapped m -> (* we assume this is never called for implementations *) - [ Wrapped.lib_interface m ]) + Wrapped.entry_modules m) ;; module With_vlib = struct diff --git a/src/dune_rules/modules.mli b/src/dune_rules/modules.mli index 57ff2ed5ca2..0518a302543 100644 --- a/src/dune_rules/modules.mli +++ b/src/dune_rules/modules.mli @@ -57,6 +57,12 @@ val obj_map : t -> Sourced_module.t Module_name.Unique.Map.t val virtual_module_names : t -> Module_name.Path.Set.t val wrapped : t -> Wrapped.t + +(** [Some m] iff the library consists of the single source module [m] + (unwrapped stanza with exactly one module). Mirrors + [With_vlib.as_singleton]. *) +val as_singleton : t -> Module.t option + val source_dirs : t -> Path.Set.t val compat_for_exn : t -> Module.t -> Module.t diff --git a/src/dune_rules/obj_dir.ml b/src/dune_rules/obj_dir.ml index e69809125aa..69fd28bfc44 100644 --- a/src/dune_rules/obj_dir.ml +++ b/src/dune_rules/obj_dir.ml @@ -638,18 +638,37 @@ module Module = struct end let dep t dep ~for_ = - match (dep : Dep.t) with - | Immediate (m, _) | Transitive (m, _) -> - (match Module.kind m with - | Module.Kind.Alias _ | Root -> None - | _ -> - let dir = - match for_ with - | Compilation_mode.Ocaml -> obj_dir t - | Melange -> obj_dir t - in - let name = Dep.basename dep in - Some (Path.Build.relative dir name)) + let m = + match (dep : Dep.t) with + | Immediate (m, _) | Transitive (m, _) -> m + in + let none = + match Module.kind m, dep with + | Module.Kind.Alias _, _ -> true + (* [Wrapped_compat] has synthesized deps in + [Dep_rules.deps_of_module] — no ocamldep rule is + registered and no [.d]/[.all-deps] file is produced. *) + | Wrapped_compat, _ -> true + (* [Root] has a synthesized [.d] written alongside [root.ml] + by [build_root_module] (one line of [: ] + format), but no transitive [.all-deps] is produced for + it: the cycle that motivated [Dep_rules.deps_of]'s + [is_alias_or_root] short-circuit (see commit a5d894525, + change #12227) lives in [.all-deps] generation, not in + [.d]. *) + | Root, Transitive _ -> true + | _ -> false + in + if none + then None + else ( + let dir = + match for_ with + | Compilation_mode.Ocaml -> obj_dir t + | Melange -> obj_dir t + in + let name = Dep.basename dep in + Some (Path.Build.relative dir name)) ;; module L = struct diff --git a/src/dune_rules/ocaml_flags.ml b/src/dune_rules/ocaml_flags.ml index b8c818790c8..56faa95279c 100644 --- a/src/dune_rules/ocaml_flags.ml +++ b/src/dune_rules/ocaml_flags.ml @@ -197,3 +197,18 @@ let allow_only_melange t = let open_flags modules = List.concat_map modules ~f:(fun name -> [ "-open"; Module_name.to_string name ]) ;; + +let extract_open_module_names flags = + let rec loop acc = function + | "-open" :: name :: rest -> + let acc = + match Module_name.of_string_opt name with + | Some m -> Module_name.Set.add acc m + | None -> acc + in + loop acc rest + | _ :: rest -> loop acc rest + | [] -> acc + in + loop Module_name.Set.empty flags +;; diff --git a/src/dune_rules/ocaml_flags.mli b/src/dune_rules/ocaml_flags.mli index c83d8c78cfe..e2dd7648565 100644 --- a/src/dune_rules/ocaml_flags.mli +++ b/src/dune_rules/ocaml_flags.mli @@ -30,3 +30,6 @@ val with_vendored_alerts : t -> t val dump : t -> Dune_lang.t list Action_builder.t val with_vendored_flags : t -> ocaml_version:Version.t -> t val open_flags : Module_name.t list -> string list + +(** Extract module names from [-open Foo] pairs in compiler flags. *) +val extract_open_module_names : string list -> Module_name.Set.t diff --git a/src/dune_rules/ocamldep.ml b/src/dune_rules/ocamldep.ml index 22fb8f5a89d..6c207651364 100644 --- a/src/dune_rules/ocamldep.ml +++ b/src/dune_rules/ocamldep.ml @@ -83,27 +83,28 @@ let parse_compilation_units ~modules = |> Option.map ~f:Modules.Sourced_module.to_module) ;; -let parse_deps_exn = - let invalid file lines = - User_error.raise - [ Pp.textf - "ocamldep returned unexpected output for %s:" - (Path.to_string_maybe_quoted file) - ; Pp.vbox - (Pp.concat_map lines ~sep:Pp.cut ~f:(fun line -> - Pp.seq (Pp.verbatim "> ") (Pp.verbatim line))) - ] - in - fun ~file lines -> - match lines with - | [] | _ :: _ :: _ -> invalid file lines - | [ line ] -> - (match String.lsplit2 line ~on:':' with - | None -> invalid file lines - | Some (basename, deps) -> - let basename = Filename.basename basename in - if basename <> Path.basename file then invalid file lines; - String.extract_blank_separated_words deps) +let invalid_ocamldep_output file lines = + User_error.raise + [ Pp.textf + "ocamldep returned unexpected output for %s:" + (Path.to_string_maybe_quoted file) + ; Pp.vbox + (Pp.concat_map lines ~sep:Pp.cut ~f:(fun line -> + Pp.seq (Pp.verbatim "> ") (Pp.verbatim line))) + ] +;; + +(* Parse the space-separated module names from one line of + [ocamldep -modules] output. The structural check (exactly one + line, one colon) catches gross format corruption; the basename + on the LHS is ignored. *) +let parse_deps ~file lines = + match lines with + | [] | _ :: _ :: _ -> invalid_ocamldep_output file lines + | [ line ] -> + (match String.lsplit2 line ~on:':' with + | None -> invalid_ocamldep_output file lines + | Some (_, deps) -> String.extract_blank_separated_words deps) ;; let transitive_deps = @@ -156,7 +157,7 @@ let deps_of ~sandbox ~modules ~sctx ~dir ~obj_dir ~ml_kind ~for_ unit = (let+ immediate_deps = Path.build ocamldep_output |> Action_builder.lines_of - >>| parse_deps_exn ~file:(Module.File.path source) + >>| parse_deps ~file:(Module.File.path source) >>| parse_module_names ~dir ~unit ~modules >>| Stdlib.( @ ) (Modules.With_vlib.implicit_deps modules ~of_:unit) in @@ -186,16 +187,45 @@ let read_deps_of ~obj_dir ~modules ~ml_kind ~for_ unit = |> Action_builder.memoize (Path.Build.to_string all_deps_file) ;; +(* Parse the raw dependency names from an ocamldep output file. The + builder for each .d file is cached by path so that + [read_immediate_deps_of] and [read_immediate_deps_raw_of] (which + may be called many times for the same module) share one memoized + [Action_builder.t] instance per file. *) +let read_immediate_deps_parsed = + let cache = Table.create (module Path.Build) 64 in + fun ~obj_dir ~ml_kind ~for_ unit -> + match Module.source ~ml_kind unit with + | None -> Action_builder.return None + | Some source -> + (match Obj_dir.Module.dep obj_dir ~for_ (Immediate (unit, ml_kind)) with + | None -> Action_builder.return None + | Some ocamldep_output -> + (match Table.find cache ocamldep_output with + | Some builder -> builder + | None -> + let builder = + Action_builder.lines_of (Path.build ocamldep_output) + |> Action_builder.map ~f:(fun lines -> + Some (parse_deps ~file:(Module.File.path source) lines)) + |> Action_builder.memoize (Path.Build.to_string ocamldep_output) + in + Table.set cache ocamldep_output builder; + builder)) +;; + let read_immediate_deps_of ~obj_dir ~modules ~ml_kind ~for_ unit = - match Module.source ~ml_kind unit with - | None -> Action_builder.return [] - | Some source -> - let ocamldep_output = - Obj_dir.Module.dep obj_dir ~for_ (Immediate (unit, ml_kind)) |> Option.value_exn - in - Action_builder.lines_of (Path.build ocamldep_output) - |> Action_builder.map ~f:(fun lines -> - parse_deps_exn ~file:(Module.File.path source) lines - |> parse_module_names ~dir:(Obj_dir.dir obj_dir) ~unit ~modules) - |> Action_builder.memoize (Path.Build.to_string ocamldep_output) + let open Action_builder.O in + let+ parsed = read_immediate_deps_parsed ~obj_dir ~ml_kind ~for_ unit in + match parsed with + | None -> [] + | Some names -> parse_module_names ~dir:(Obj_dir.dir obj_dir) ~unit ~modules names +;; + +let read_immediate_deps_raw_of ~obj_dir ~ml_kind ~for_ unit = + let open Action_builder.O in + let+ parsed = read_immediate_deps_parsed ~obj_dir ~ml_kind ~for_ unit in + match parsed with + | None -> Module_name.Set.empty + | Some names -> Module_name.Set.of_list_map names ~f:Module_name.of_checked_string ;; diff --git a/src/dune_rules/ocamldep.mli b/src/dune_rules/ocamldep.mli index d455459262e..b86e7de6b45 100644 --- a/src/dune_rules/ocamldep.mli +++ b/src/dune_rules/ocamldep.mli @@ -32,3 +32,14 @@ val read_immediate_deps_of -> for_:Compilation_mode.t -> Module.t -> Module.t list Action_builder.t + +(** [read_immediate_deps_raw_of ~obj_dir ~ml_kind ~for_ unit] returns the raw + module names from ocamldep output without filtering against the stanza's + module set. This preserves cross-library references that + [read_immediate_deps_of] discards. *) +val read_immediate_deps_raw_of + : obj_dir:Path.Build.t Obj_dir.t + -> ml_kind:Ml_kind.t + -> for_:Compilation_mode.t + -> Module.t + -> Module_name.Set.t Action_builder.t diff --git a/src/dune_rules/parameterised_rules.ml b/src/dune_rules/parameterised_rules.ml index a16e7ed23f4..5cecf75887d 100644 --- a/src/dune_rules/parameterised_rules.ml +++ b/src/dune_rules/parameterised_rules.ml @@ -408,6 +408,7 @@ let external_dep_rules ~sctx ~dir ~scope lib_name = ~impl:Virtual_rules.no_implements ~for_ ~modules + ~has_library_deps:true in () ;; diff --git a/src/dune_rules/virtual_rules.ml b/src/dune_rules/virtual_rules.ml index d353ae5b1a1..d1329b255f6 100644 --- a/src/dune_rules/virtual_rules.ml +++ b/src/dune_rules/virtual_rules.ml @@ -11,6 +11,11 @@ type t = let no_implements = No_implements +let is_implementation = function + | Virtual _ | Parameter _ -> true + | No_implements -> false +;; + let setup_copy_rules_for_impl ~sctx ~dir t = match t with | No_implements | Parameter _ -> Memo.return () diff --git a/src/dune_rules/virtual_rules.mli b/src/dune_rules/virtual_rules.mli index a56d595d1de..90146957ee2 100644 --- a/src/dune_rules/virtual_rules.mli +++ b/src/dune_rules/virtual_rules.mli @@ -9,6 +9,7 @@ val setup_copy_rules_for_impl -> unit Memo.t val no_implements : t +val is_implementation : t -> bool val impl : Super_context.t diff --git a/test/blackbox-tests/test-cases/enabled_if/eif-context_name.t/run.t b/test/blackbox-tests/test-cases/enabled_if/eif-context_name.t/run.t index a0bb583f206..7bb9fb505fd 100644 --- a/test/blackbox-tests/test-cases/enabled_if/eif-context_name.t/run.t +++ b/test/blackbox-tests/test-cases/enabled_if/eif-context_name.t/run.t @@ -30,7 +30,6 @@ dune >= 2.8 18 | (libraries bar)) ^^^ Error: Library "bar" in _build/default is hidden (unsatisfied 'enabled_if'). - -> required by _build/default/.bar_exe.eobjs/native/dune__exe__Bar_exe.cmx -> required by _build/default/bar_exe.exe [1] diff --git a/test/blackbox-tests/test-cases/enabled_if/eif-ocaml_version.t/run.t b/test/blackbox-tests/test-cases/enabled_if/eif-ocaml_version.t/run.t index 8472fd69da6..ecf6235087c 100644 --- a/test/blackbox-tests/test-cases/enabled_if/eif-ocaml_version.t/run.t +++ b/test/blackbox-tests/test-cases/enabled_if/eif-ocaml_version.t/run.t @@ -10,6 +10,5 @@ This one is disabled (version too low) ^^^^^^^^^^ Error: Library "futurecaml" in _build/default is hidden (unsatisfied 'enabled_if'). - -> required by _build/default/.main2.eobjs/native/dune__exe__Main2.cmx -> required by _build/default/main2.exe [1] diff --git a/test/blackbox-tests/test-cases/inline-tests/alias-cycle.t b/test/blackbox-tests/test-cases/inline-tests/alias-cycle.t index e6d5c3aaa9a..761b37328ea 100644 --- a/test/blackbox-tests/test-cases/inline-tests/alias-cycle.t +++ b/test/blackbox-tests/test-cases/inline-tests/alias-cycle.t @@ -26,19 +26,12 @@ turn depends on the inline-test-name alias of the inline tests of the library. > (echo "let message = \"Hello world\"")))) > EOF -This kind of cycle has a difficult to understand error message. - $ dune build 2>&1 | grep -vwE "sed" +This kind of cycle has a difficult to understand error message. The specific +ordering of nodes in the cycle walk varies across environments and depends +on rule-scheduling order, so the test hides the walk nodes (which change) +and keeps only the invariants: the error header and the presence of the +runtest alias in the walk. + $ dune build 2>&1 | dune_cmd delete '^(File |\d+ \|| _build|-> _build|-> required by|-> %\{|.*sed: )' Error: Dependency cycle between: - _build/default/.foo_simple.objs/foo_simple__Bar.impl.all-deps - -> _build/default/.foo_simple.objs/byte/foo_simple__Bar.cmi - -> _build/default/.foo_simple.inline-tests/.t.eobjs/native/dune__exe__Main.cmx - -> _build/default/.foo_simple.inline-tests/inline-test-runner.exe -> alias runtest-foo_simple in dune:9 - -> _build/default/bar.ml - -> _build/default/.foo_simple.objs/foo_simple__Bar.impl.d - -> _build/default/.foo_simple.objs/foo_simple__Bar.impl.all-deps - -> required by _build/default/.foo_simple.objs/byte/foo_simple__Bar.cmo - -> required by _build/default/foo_simple.cma - -> required by alias all - -> required by alias default [1] diff --git a/test/blackbox-tests/test-cases/invalid-dune-package.t b/test/blackbox-tests/test-cases/invalid-dune-package.t index 055e624acc9..a4ce13c45dc 100644 --- a/test/blackbox-tests/test-cases/invalid-dune-package.t +++ b/test/blackbox-tests/test-cases/invalid-dune-package.t @@ -17,6 +17,5 @@ Now we attempt to use an invalid dune-package library: $ OCAMLPATH=$PWD/findlib dune exec ./foo.exe File "$TESTCASE_ROOT/findlib/baz/dune-package", line 1, characters 0-0: Error: Invalid first line, expected: (lang ) - -> required by _build/default/.foo.eobjs/native/dune__exe__Foo.cmx -> required by _build/default/foo.exe [1] diff --git a/test/blackbox-tests/test-cases/jsoo/jsoo-noautolink.t b/test/blackbox-tests/test-cases/jsoo/jsoo-noautolink.t index 04d5c94f1c9..70cba409ee7 100644 --- a/test/blackbox-tests/test-cases/jsoo/jsoo-noautolink.t +++ b/test/blackbox-tests/test-cases/jsoo/jsoo-noautolink.t @@ -39,6 +39,10 @@ The file dlllibA_stubs.so should not appear in the next list. _build/default/.mainA.eobjs/byte/dune__exe__MainA.cmo _build/default/.mainA.eobjs/byte/dune__exe__MainA.cmt _build/default/.mainA.eobjs/byte/dune__exe__MainA.cmti + _build/default/.mainA.eobjs/dune__exe__MainA.impl.all-deps + _build/default/.mainA.eobjs/dune__exe__MainA.impl.d + _build/default/.mainA.eobjs/dune__exe__MainA.intf.all-deps + _build/default/.mainA.eobjs/dune__exe__MainA.intf.d _build/default/.merlin-conf _build/default/.merlin-conf/exe-mainA _build/default/.merlin-conf/lib-libA diff --git a/test/blackbox-tests/test-cases/optional-executable.t b/test/blackbox-tests/test-cases/optional-executable.t index 530db7e8855..ed4df47fa1f 100644 --- a/test/blackbox-tests/test-cases/optional-executable.t +++ b/test/blackbox-tests/test-cases/optional-executable.t @@ -26,7 +26,6 @@ Test optional executable 3 | (libraries does-not-exist) ^^^^^^^^^^^^^^ Error: Library "does-not-exist" not found. - -> required by _build/default/.x.eobjs/native/dune__exe__X.cmx -> required by _build/default/x.exe -> required by alias all [1] @@ -149,7 +148,6 @@ present even if the binary is not optional. 3 | (libraries doesnotexistatall) ^^^^^^^^^^^^^^^^^ Error: Library "doesnotexistatall" not found. - -> required by _build/default/exe/.bar.eobjs/native/dune__exe__Bar.cmx -> required by _build/default/exe/bar.exe -> required by _build/install/default/bin/dunetestbar -> required by %{bin:dunetestbar} at dune:3 diff --git a/test/blackbox-tests/test-cases/overlapping-deps.t b/test/blackbox-tests/test-cases/overlapping-deps.t index 80222875de2..fa9df2de3b6 100644 --- a/test/blackbox-tests/test-cases/overlapping-deps.t +++ b/test/blackbox-tests/test-cases/overlapping-deps.t @@ -104,6 +104,5 @@ We also make sure the error exists for executables: -> required by library "some_package1" in $TESTCASE_ROOT/use/../external/_build/install/default/lib/some_package1 -> required by executable bar in proj2/dune:2 - -> required by _build/default/proj2/.bar.eobjs/native/dune__exe__Bar.cmx -> required by _build/default/proj2/bar.exe [1] diff --git a/test/blackbox-tests/test-cases/oxcaml/library-field-parameters.t b/test/blackbox-tests/test-cases/oxcaml/library-field-parameters.t index 105e0bbd9c0..b0fd83f0a9d 100644 --- a/test/blackbox-tests/test-cases/oxcaml/library-field-parameters.t +++ b/test/blackbox-tests/test-cases/oxcaml/library-field-parameters.t @@ -345,7 +345,6 @@ required parameters. 1 | (executable (name bin) (libraries lib)) ^^^ Error: Missing argument for parameter "project.a". - -> required by _build/default/bin/.bin.eobjs/native/dune__exe__Bin.cmx -> required by _build/default/bin/bin.exe -> required by alias bin/all -> required by alias default diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/auto-wrapped-child-reexport.t b/test/blackbox-tests/test-cases/per-module-lib-deps/auto-wrapped-child-reexport.t new file mode 100644 index 00000000000..c2b484e6108 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/auto-wrapped-child-reexport.t @@ -0,0 +1,93 @@ +Observational baseline: a consumer reaches a dep library's modules +through a child of an auto-wrapped sibling library. The sibling is +opened via the [-open] compiler flag and the child's source exposes +the dep library through a transparent module alias. On trunk, +[consumer] correctly rebuilds when the dep library's interface +changes, because the cctx-wide compile-rule deps cover every +library in the stanza's [(libraries ...)] closure. + +The structural shape mirrors menhir's [base]/[middle] arrangement: +[base] is auto-wrapped (no [base.ml] in source — dune generates +the wrapper) with a child module that re-exports a third-party +dep; [middle] uses [-open Base] and reaches the dep through that +child. Menhir uses [include Vendored_pprint]; this test uses a +transparent alias instead, for the precision-bug reason described +below. The reference chain still crosses library boundaries +through the auto-wrapped sibling's child, not through a +hand-written wrapper. + +Records the consumer's rebuild count as a regression guard for +changes that narrow compile-rule deps per module. + +Structure: [lib_a] is unwrapped with module [Original_name]. +[lib_re_export] is wrapped by dune defaults (no [lib_re_export.ml] +file — dune auto-generates the wrapper); child [pprint.ml] +contains a transparent alias [module Re = Original_name]; +[filler.ml] is just there to keep the lib non-singleton. [lib_b] +depends on [lib_re_export] and uses [-open Lib_re_export] in its +flags; [consumer.ml] writes [Pprint.Re.x] — naming the wrapped +lib's child [Pprint] but not [lib_a] or the wrapper +[Lib_re_export]. + +The transparent-alias shape (rather than [include]) is what makes +[lib_a] genuinely invisible to a per-module dep filter that only +walks the auto-generated wrapper: with [-no-alias-deps], [Pprint]'s +[.cmi] content does not depend on [Original_name.cmi], so a glob +over [lib_re_export]'s objdir does not capture [Original_name.mli] +changes either. The cctx-wide compile-rule deps still cover +[lib_a] on trunk, so [consumer] rebuilds. + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (library (name lib_a) (wrapped false) (modules original_name)) + > (library + > (name lib_re_export) + > (modules pprint filler) + > (libraries lib_a)) + > (library + > (name lib_b) + > (wrapped false) + > (modules consumer) + > (libraries lib_re_export) + > (flags (:standard -open Lib_re_export))) + > EOF + + $ cat > original_name.ml < let x = "hello" + > EOF + $ cat > original_name.mli < val x : string + > EOF + + $ cat > pprint.ml < module Re = Original_name + > EOF + $ cat > filler.ml < let placeholder = () + > EOF + + $ cat > consumer.ml < let _ = Pprint.Re.x + > EOF + + $ dune build @check + +Edit [lib_a]'s interface. [consumer] reaches [lib_a]'s +[Original_name] through [Pprint] (child of auto-wrapped +[Lib_re_export]). The cctx-wide compile-rule deps include +[lib_a], so [consumer] rebuilds: + + $ cat > original_name.mli < val x : string + > val y : int + > EOF + $ cat > original_name.ml < let x = "hello" + > let y = 42 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer"))] | length' + 1 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/basic-wrapped.t b/test/blackbox-tests/test-cases/per-module-lib-deps/basic-wrapped.t index eb203482193..91ae8e96e4b 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/basic-wrapped.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/basic-wrapped.t @@ -58,8 +58,8 @@ Change mylib's interface: > let new_function () = "hello" > EOF -No_use_lib is recompiled even though it doesn't reference Mylib: +No_use_lib is not recompiled because it doesn't reference Mylib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("No_use_lib"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/cross-lib-ppx.t b/test/blackbox-tests/test-cases/per-module-lib-deps/cross-lib-ppx.t new file mode 100644 index 00000000000..58dc0cd90ab --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/cross-lib-ppx.t @@ -0,0 +1,67 @@ +Cross-library reads of ocamldep output must tolerate ppx-transformed +sources. When the target dependency library uses a preprocessor, its +[.d] file's left-hand basename is [foo.pp.ml] — the pp-transformed +source fed to ocamldep by the rule. The per-module filter's BFS in +the consumer holds a raw [Module.t] whose source path is [foo.ml], +so a strict basename check in the [.d] parser would mismatch and +raise "ocamldep returned unexpected output". The reader must parse +leniently; the producing rule already validates the basename. + +See: https://github.com/ocaml/dune/pull/14116 + + $ cat > dune-project < (lang dune 3.0) + > EOF + +A trivial preprocessor that passes its input through unchanged. The +critical property is that dune's pp pipeline writes a [.pp.ml] file +and feeds it to ocamldep, not the original [.ml]. + + $ mkdir pp + $ cat > pp/dune < (executable (name pp)) + > EOF + $ cat > pp/pp.ml < let () = + > let ic = open_in_bin Sys.argv.(1) in + > try + > while true do print_char (input_char ic) done + > with End_of_file -> () + > EOF + +[dep] is a tight-eligible library (local, unwrapped, multi-module so +ocamldep is not short-circuited) whose modules are preprocessed: + + $ mkdir dep + $ cat > dep/dune < (library + > (name dep) + > (wrapped false) + > (preprocess (action (run %{exe:../pp/pp.exe} %{input-file})))) + > EOF + $ cat > dep/mod_a.ml < let v = 1 + > EOF + $ cat > dep/mod_b.ml < let v = 2 + > EOF + +[consumer] references [Mod_a] from [dep]. Compiling [c.ml] triggers +the cross-library BFS to read [dep/mod_a.impl.d] — whose producer +ran ocamldep on [mod_a.pp.ml]. The BFS must read this [.d] with a +raw [Module.t] for [mod_a]: + + $ mkdir consumer + $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries dep)) + > EOF + $ cat > consumer/c.ml < let _ = Mod_a.v + > EOF + $ cat > consumer/d.ml < let _ = () + > EOF + +Build must succeed: + + $ dune build @check diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-deps-preserved.t b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-deps-preserved.t index 86c419fc590..b3ea0203ad6 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-deps-preserved.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-deps-preserved.t @@ -1,7 +1,9 @@ -Verify that library file deps are declared for module compilation rules. +Verify the cm_kind/-opaque rules for library file deps in compile rules. -Every non-alias module should declare glob deps on its library -dependencies' .cmi files. +[mylib] is a wrapped library exposing one entry [Mylib]. The +executable has two modules: [Uses_lib] which references [Mylib] in +its [.ml] (but not its [.mli]) and [Main] which references only +[Uses_lib]. [Main] has no [.mli]. $ cat > dune-project < (lang dune 3.23) @@ -33,12 +35,23 @@ dependencies' .cmi files. $ dune build ./main.exe -Both modules declare glob deps on mylib's .cmi files: +[Uses_lib.cmx] keeps the glob: [Uses_lib.ml] references [Mylib], and +[mylib] is wrapped — wrapped libraries fall back to a glob over their +public cmi dir even under per-module filtering, since ocamldep's +[-modules] output cannot distinguish [Foo.Bar.x] from [Foo.Baz.x]. $ dune rules --root . --format=json --deps _build/default/.main.eobjs/native/dune__exe__Uses_lib.cmx | > jq -r 'include "dune"; .[] | depsGlobPredicates' *.cmi +[Main.cmx] has no inter-library deps. [Main.ml] references only +[Uses_lib], an intra-library module filtered out before the +inter-library filter runs. [Uses_lib] is a transitive intra-library +dep; its [.mli] has no cross-library references. [Uses_lib.ml] does +reference [Mylib], but those references propagate to the consumer +only through [Uses_lib.cmx] for cross-module inlining. Under the +default profile dune builds with [-opaque], which disables that +inlining, so [Uses_lib.ml]'s references are sealed behind its [.cmi]. + $ dune rules --root . --format=json --deps _build/default/.main.eobjs/native/dune__exe__Main.cmx | > jq -r 'include "dune"; .[] | depsGlobPredicates' - *.cmi diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-unwrapped.t b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-unwrapped.t index 7ca6696af99..b1e9a77851d 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-unwrapped.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-unwrapped.t @@ -1,8 +1,8 @@ -Baseline: library-to-library recompilation (unwrapped). +Per-module library-to-library filtering (unwrapped). When an unwrapped library A depends on an unwrapped library B with multiple -modules, and one module in B changes, all modules in A are recompiled due to -coarse dependency analysis. +modules, modules of A that do not reference a given module of B must not be +recompiled when that module of B changes. See: https://github.com/ocaml/dune/issues/4572 @@ -80,11 +80,11 @@ Change only alpha.mli: > let new_alpha_fn () = "alpha" > EOF -uses_beta is recompiled even though it only references Beta, not Alpha: +uses_beta references Beta only, not Alpha, so it is not recompiled: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("uses_beta"))] | length' - 2 + 0 Change only beta.mli: @@ -97,8 +97,8 @@ Change only beta.mli: > let new_beta_fn () = "beta" > EOF -uses_alpha is recompiled even though it only references Alpha, not Beta: +uses_alpha references Alpha only, not Beta, so it is not recompiled: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("uses_alpha"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-wrapped.t b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-wrapped.t index 72cbb718d9f..bc690d0beeb 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-wrapped.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-to-lib-wrapped.t @@ -62,8 +62,8 @@ See: https://github.com/ocaml/dune/issues/4572 > let new_base_fn () = "hello" > EOF -Standalone in middle_lib is recompiled even though it doesn't use base_lib: +Standalone in middle_lib is not recompiled because it doesn't use base_lib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Standalone"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/lib-vs-lib-name-collision.t b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-vs-lib-name-collision.t new file mode 100644 index 00000000000..549dfaf0010 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/lib-vs-lib-name-collision.t @@ -0,0 +1,72 @@ +Two unwrapped libraries expose an entry module of the same name. +A consumer library lists both and references the name in its code. +OCaml's [-I] path resolution picks the lib whose [-I] comes first +on the command line (the order of declared libraries in +[(libraries ...)]), so the consumer compiles against that lib's +version of the module. The other lib's same-named module is +shadowed — unreachable from consumer code. + +This test is observational: it records the rebuild-target count +for a consumer module after editing the *losing* (shadowed) lib's +same-named module. The expected count on current [main] is +positive because cross-library dep tracking is library-level: the +consumer depends on a glob over each declared library's public +cmi directory, so any change to the losing lib's public cmis +invalidates the consumer. + +A future per-module dependency filter (#4572) would not +automatically improve this case: without qualified-path analysis +the filter cannot know which lib's [Shared] the consumer actually +resolves through, and must conservatively depend on both. + +See: https://github.com/ocaml/dune/issues/4572 + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ mkdir lib_a + $ cat > lib_a/dune < (library (name lib_a) (wrapped false)) + > EOF + $ cat > lib_a/shared.ml < let from_a = "a" + > EOF + + $ mkdir lib_b + $ cat > lib_b/dune < (library (name lib_b) (wrapped false)) + > EOF + $ cat > lib_b/shared.ml < let from_b = "b" + > EOF + +[consumer] lists [lib_a] first, so [-I lib_a/.objs] comes before +[-I lib_b/.objs] on the command line and OCaml resolves [Shared] +to [lib_a/shared.cmi]: + + $ mkdir consumer + $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries lib_a lib_b)) + > EOF + $ cat > consumer/c.ml < let _ = Shared.from_a + > EOF + $ cat > consumer/d.ml < let _ = () + > EOF + + $ dune build @check + +Edit [lib_b]'s [Shared] — which the consumer does *not* resolve +through, because [lib_a] wins under [-I] order. Consumer still +rebuilds because its dependency on [lib_b]'s object directory is +a glob: + + $ cat > lib_b/shared.ml < let from_b = "b" + > let also_from_b = 42 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))] | length > 0' + true diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/multiple-libraries.t b/test/blackbox-tests/test-cases/per-module-lib-deps/multiple-libraries.t index da2810d728b..84683c7bd2e 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/multiple-libraries.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/multiple-libraries.t @@ -78,11 +78,11 @@ Change only mylib's interface: > let new_function () = "hello" > EOF -Uses_other is recompiled even though it only uses Otherlib, not Mylib: +Uses_other is not recompiled because it only uses Otherlib, not Mylib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Uses_other"))] | length' - 2 + 0 Change only otherlib's interface: @@ -95,8 +95,8 @@ Change only otherlib's interface: > let new_other_fn s = s ^ "!" > EOF -Uses_lib is recompiled even though it only uses Mylib, not Otherlib: +Uses_lib is not recompiled because it only uses Mylib, not Otherlib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Uses_lib"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/no-ocamldep-leaf-lib.t b/test/blackbox-tests/test-cases/per-module-lib-deps/no-ocamldep-leaf-lib.t new file mode 100644 index 00000000000..68a6f5b0941 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/no-ocamldep-leaf-lib.t @@ -0,0 +1,50 @@ +A tight-eligible dependency library that is a leaf: one module, no +library deps of its own. Dune's [Dep_rules.skip_ocamldep] short- +circuits ocamldep for this shape — no [.d] rules exist for [leaf]. +The per-module filter's cross-library walk must recognise this and +not demand a nonexistent [leaf/mod_leaf.impl.d]; otherwise the +consumer's compile fails with "No rule found" during rule +evaluation. + +The guard lives in [Lib_index.no_ocamldep]. This test proves the +consumer builds successfully even though the BFS names [leaf] in +its initial frontier. + +See: https://github.com/ocaml/dune/issues/4572 + + $ cat > dune-project < (lang dune 3.0) + > EOF + +[leaf]: unwrapped, single-module, no library dependencies. This is +exactly the shape that triggers [skip_ocamldep]: + + $ mkdir leaf + $ cat > leaf/dune < (library (name leaf) (wrapped false)) + > EOF + $ cat > leaf/mod_leaf.ml < let v = 1 + > EOF + +[consumer]: multi-module library that depends on [leaf] and +references [Mod_leaf] from one of its modules. The consumer itself +has library deps, so its own ocamldep runs; what we're probing is +whether the cross-library walk tries to read [leaf]'s ocamldep: + + $ mkdir consumer + $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries leaf)) + > EOF + $ cat > consumer/c.ml < let _ = Mod_leaf.v + > EOF + $ cat > consumer/d.ml < let _ = () + > EOF + +Build must succeed. Without the [no_ocamldep] guard, the BFS would +demand [leaf/.leaf.objs/mod_leaf.impl.d] and the rule engine would +fail: + + $ dune build @check diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-cmx-deps-external.t b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-cmx-deps-external.t new file mode 100644 index 00000000000..3529d708880 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-cmx-deps-external.t @@ -0,0 +1,43 @@ +A consumer's [.cmx] compilation rule depends on an external library's +[.cmx] under both the release and dev profiles. Counterpart to +[opaque-cmx-deps-local.t], which shows the dev-profile behaviour that +omits the [.cmx] from the dep set for *local* libraries. The [unix] +library, shipped with OCaml and resolved through findlib, plays the +role of "external". + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (executable (name main) (libraries unix)) + > EOF + $ cat > main.ml < let () = ignore (Unix.gettimeofday ()) + > EOF + +--- Release profile (opaque=false): both .cmi and .cmx globs --- + + $ cat > dune-workspace < (lang dune 3.0) + > (profile release) + > EOF + + $ dune build ./main.exe + $ dune rules --root . --format=json --deps _build/default/.main.eobjs/native/dune__exe__Main.cmx | + > jq -r 'include "dune"; .[] | depsGlobPredicates' | sort -u + *.cmi + *.cmx + +--- Dev profile (opaque=true): both .cmi and .cmx globs (unchanged for external libs) --- + + $ cat > dune-workspace < (lang dune 3.0) + > (profile dev) + > EOF + + $ dune build ./main.exe + $ dune rules --root . --format=json --deps _build/default/.main.eobjs/native/dune__exe__Main.cmx | + > jq -r 'include "dune"; .[] | depsGlobPredicates' | sort -u + *.cmi + *.cmx diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-cmx-deps-local.t b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-cmx-deps-local.t new file mode 100644 index 00000000000..5b7f64f8e42 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-cmx-deps-local.t @@ -0,0 +1,48 @@ +A consumer's [.cmx] compilation rule depends on a local library's +[.cmx] under the release profile (opaque=false), but only on the +[.cmi] under the dev profile (opaque=true). External libraries +behave differently; see [opaque-cmx-deps-external.t]. + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ mkdir lib + $ cat > lib/dune < (library (name mylib)) + > EOF + $ cat > lib/mylib.ml < let v = 42 + > EOF + + $ cat > dune < (executable (name main) (libraries mylib)) + > EOF + $ cat > main.ml < let () = print_int Mylib.v + > EOF + +--- Release profile (opaque=false): both .cmi and .cmx globs --- + + $ cat > dune-workspace < (lang dune 3.0) + > (profile release) + > EOF + + $ dune build ./main.exe + $ dune rules --root . --format=json --deps _build/default/.main.eobjs/native/dune__exe__Main.cmx | + > jq -r 'include "dune"; .[] | depsGlobPredicates' | sort -u + *.cmi + *.cmx + +--- Dev profile (opaque=true): only .cmi glob --- + + $ cat > dune-workspace < (lang dune 3.0) + > (profile dev) + > EOF + + $ dune build ./main.exe + $ dune rules --root . --format=json --deps _build/default/.main.eobjs/native/dune__exe__Main.cmx | + > jq -r 'include "dune"; .[] | depsGlobPredicates' | sort -u + *.cmi diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-mli-change.t b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-mli-change.t new file mode 100644 index 00000000000..01de3b10287 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque-mli-change.t @@ -0,0 +1,79 @@ +A [.mli] change in a dependency library rebuilds consumers under +both the release (opaque=false) and dev (opaque=true) profiles. +Opaque mode does not affect [.cmi] propagation; it only affects +whether cross-module inlining tracks a dep's [.cmx]. + +Companion to [opaque.t], which covers the [.ml]-only change axis. + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ mkdir lib + $ cat > lib/dune < (library (name mylib)) + > EOF + $ cat > lib/mylib.ml < let v = 42 + > EOF + $ cat > lib/mylib.mli < val v : int + > EOF + + $ cat > dune < (executable (name main) (libraries mylib)) + > EOF + $ cat > main.ml < let () = print_int Mylib.v + > EOF + +--- Release profile (opaque=false): .mli change rebuilds consumer --- + + $ cat > dune-workspace < (lang dune 3.0) + > (profile release) + > EOF + + $ dune build ./main.exe + +Add a new declaration to both [.ml] and [.mli] (paired so no value is +left unexported, which would trip warning 32 under dev): + + $ cat > lib/mylib.ml < let v = 42 + > let extra () = 0 + > EOF + $ cat > lib/mylib.mli < val v : int + > val extra : unit -> int + > EOF + + $ dune build ./main.exe + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("dune__exe__Main"))] | length' + 1 + +--- Dev profile (opaque=true): .mli change still rebuilds consumer --- + + $ cat > dune-workspace < (lang dune 3.0) + > (profile dev) + > EOF + + $ dune build ./main.exe + +Add another paired declaration: + + $ cat > lib/mylib.ml < let v = 42 + > let extra () = 0 + > let helper x = x + 1 + > EOF + $ cat > lib/mylib.mli < val v : int + > val extra : unit -> int + > val helper : int -> int + > EOF + + $ dune build ./main.exe + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("dune__exe__Main"))] | length' + 1 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/opaque.t b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque.t index 17507352124..741aa8e25c7 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/opaque.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/opaque.t @@ -62,11 +62,11 @@ Change ONLY the implementation (not the interface): > let value = 43 > EOF -No_use_lib is recompiled even though it doesn't reference Mylib: +No_use_lib is not recompiled because it doesn't reference Mylib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("No_use_lib"))] | length' - 1 + 0 --- Dev profile (opaque=true): .cmx deps are NOT tracked for local libs --- diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/private-modules.t b/test/blackbox-tests/test-cases/per-module-lib-deps/private-modules.t new file mode 100644 index 00000000000..94d67880311 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/private-modules.t @@ -0,0 +1,49 @@ +An unwrapped library with [private_modules] forces dune to use a +dedicated public cmi directory: public modules' cmis live in a +separate [.objs/public_cmi] directory, produced by a copy rule +from the internal object dir. Per-module tight deps must emit +deps on the *public* cmi path, not the internal one — otherwise +the produce-public-cmi rule doesn't run and sandboxed compiles +fail to find the cmi. + +This test exercises the [Obj_dir.Module.cm_public_file] path in +[Lib_file_deps.deps_of_entry_modules]. + +See: https://github.com/ocaml/dune/issues/4572 + + $ cat > dune-project < (lang dune 3.0) + > EOF + +[dep]: unwrapped library with one public entry module [Pub] and +one private module [Priv]. Consumers can see [Pub] via [-I] +search but not [Priv]: + + $ mkdir dep + $ cat > dep/dune < (library + > (name dep) + > (wrapped false) + > (private_modules priv)) + > EOF + $ cat > dep/pub.ml < let greeting () = Priv.helper ^ "!" + > EOF + $ cat > dep/priv.ml < let helper = "hi" + > EOF + +[consumer]: references [Pub] from [dep]: + + $ mkdir consumer + $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries dep)) + > EOF + $ cat > consumer/c.ml < let v = Pub.greeting () + > EOF + $ cat > consumer/d.ml < let _ = () + > EOF + + $ dune build @check diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/sibling-unreferenced-lib.t b/test/blackbox-tests/test-cases/per-module-lib-deps/sibling-unreferenced-lib.t new file mode 100644 index 00000000000..e500a2fa00b --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/sibling-unreferenced-lib.t @@ -0,0 +1,56 @@ +Multi-module consumer library with a sibling module that references +no module of its declared dependency library. Under per-module +library dep filtering, the unreferenced sibling is not rebuilt when +the dependency's interface changes. + +[libB] is an unwrapped library with two modules. [modB2] references +[ModA2]; [modB1] references nothing from [libA]. Ocamldep's output +for [modB1] is empty of references to [libA], so the filter drops +[libA] entirely from [modB1]'s deps. Editing [modA2]'s interface +does not invalidate anything in [modB1]'s dep set, so [modB1] stays +built. + +See: https://github.com/ocaml/dune/issues/4572 +See: https://github.com/ocaml/dune/pull/14116#issuecomment-4301275263 + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (library (name libA) (wrapped false) (modules modA1 modA2)) + > (library (name libB) (wrapped false) (modules modB1 modB2) (libraries libA)) + > EOF + + $ cat > modA1.ml < let x = 42 + > EOF + $ cat > modA2.ml < let x = 43 + > EOF + $ cat > modA2.mli < val x : int + > EOF + $ cat > modB1.ml < let x = 12 + > EOF + $ cat > modB2.ml < let x = ModA2.x + > EOF + + $ dune build @check + +Edit modA2's interface. modB1 references nothing in libA. Record the +number of modB1 rebuild targets observed in the trace: + + $ cat > modA2.mli < val x : int + > val y : string + > EOF + $ cat > modA2.ml < let x = 43 + > let y = "hello" + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("modB1"))] | length' + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-lib.t b/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-lib.t index fde4d0693a8..c608c55a858 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-lib.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-lib.t @@ -1,11 +1,10 @@ -Single-module library consumers and recompilation behavior. +Per-module filtering for single-module library consumers. -When a consumer library has only one module, dune skips ocamldep for that -stanza as an optimization (no intra-stanza deps to compute). This means -per-module library dependency filtering cannot determine which libraries -the module actually references, so the consumer depends on all library -files via glob. Modifying an unused module in a dependency triggers -unnecessary recompilation of the consumer module's .cmo/.cmx. +A consumer library with a single module still runs ocamldep when it has +library dependencies, and the per-module filter uses that output to +dep on only the specific entry modules of an unwrapped dependency that +the consumer actually references. Modifying an unreferenced module of +the dependency does not recompile the consumer. See: https://github.com/ocaml/dune/issues/4572 @@ -61,25 +60,8 @@ Modify only the unused module: > let new_fn () = "new" > EOF -uses_alpha.cmx is recompiled even though uses_alpha.ml only references -Alpha, not Unused. This is because the consumer_lib stanza has a single -module, so dune skips ocamldep for it and falls back to glob deps on all -of base_lib's .cmi files. The trace shows compilation targets for -uses_alpha being rebuilt: +uses_alpha.ml references Alpha only, not Unused, so it is not recompiled: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("uses_alpha"))]' - [ - { - "target_files": [ - "_build/default/consumer_lib/.consumer_lib.objs/byte/uses_alpha.cmi", - "_build/default/consumer_lib/.consumer_lib.objs/byte/uses_alpha.cmti" - ] - }, - { - "target_files": [ - "_build/default/consumer_lib/.consumer_lib.objs/native/uses_alpha.cmx", - "_build/default/consumer_lib/.consumer_lib.objs/native/uses_alpha.o" - ] - } - ] + [] diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-unreferenced-lib.t b/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-unreferenced-lib.t new file mode 100644 index 00000000000..2cbd5585af9 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/single-module-unreferenced-lib.t @@ -0,0 +1,50 @@ +Single-module consumer library whose only module references no module +of its declared dependency library. Under per-module library dep +filtering, the consumer is not rebuilt when the dependency's +interface changes. + +[libB] declares [(libraries libA)] but [modB.ml] does not reference +any module of [libA]. Ocamldep runs on [modB] (single-module stanzas +with library deps are no longer short-circuited), reports no +references to [libA], and the filter drops [libA] from [modB]'s deps +entirely. Editing [modA]'s interface no longer invalidates anything +in [modB]'s dep set, so [modB] stays built. + +See: https://github.com/ocaml/dune/issues/4572 +See: https://github.com/ocaml/dune/pull/14116#issuecomment-4286949811 + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (library (name libA) (wrapped false) (modules modA)) + > (library (name libB) (wrapped false) (modules modB) (libraries libA)) + > EOF + + $ cat > modA.ml < let x = 42 + > EOF + $ cat > modA.mli < val x : int + > EOF + $ cat > modB.ml < let x = 12 + > EOF + + $ dune build @check + +Edit modA's interface. modB does not reference modA. Record the +number of modB rebuild targets observed in the trace: + + $ cat > modA.mli < val x : int + > val y : string + > EOF + $ cat > modA.ml < let x = 42 + > let y = "hello" + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("modB"))] | length' + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/stdlib-modules.t b/test/blackbox-tests/test-cases/per-module-lib-deps/stdlib-modules.t index 81bc097ecb7..997676c152e 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/stdlib-modules.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/stdlib-modules.t @@ -55,8 +55,8 @@ See: https://github.com/ocaml/dune/issues/4572 > let new_function () = "hello" > EOF -Uses_stdlib is recompiled even though it only uses Printf, not Mylib: +Uses_stdlib is not recompiled because it only uses Printf, not Mylib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Uses_stdlib"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/transitive-unreferenced-lib.t b/test/blackbox-tests/test-cases/per-module-lib-deps/transitive-unreferenced-lib.t new file mode 100644 index 00000000000..5c6ba4a2765 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/transitive-unreferenced-lib.t @@ -0,0 +1,43 @@ +An intermediate library [libB] declares [(libraries libA)] but its +module [modB] does not reference any of [libA]'s modules. The +consumer [main] uses [libB] and so transitively gains [libA] in its +compilation context. + +The cross-library walk has full visibility into [libA] (local, +unwrapped, every entry has a known module), and finishes without +adding any of [libA]'s entry modules to the reference closure. The +filter therefore drops [libA] from [main]'s compile-rule deps +entirely; the link rule still pulls [libA] in for executables that +need it. + +Editing [modA1.ml] does not invalidate [main]. + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (library (name libA) (wrapped false) (modules modA1 modA2)) + > (library (name libB) (wrapped false) (modules modB) (libraries libA)) + > (executable (name main) (modules main) (libraries libB)) + > EOF + + $ cat > modA1.ml < let x = 42 + > EOF + $ cat > modA2.ml < let x = 43 + > EOF + $ cat > modB.ml < let x = 42 + > EOF + $ cat > main.ml < let _ = ModB.x + > EOF + + $ dune build @check + + $ echo > modA1.ml + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("dune__exe__Main"))] | length' + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/transitive-unreferenced-module.t b/test/blackbox-tests/test-cases/per-module-lib-deps/transitive-unreferenced-module.t new file mode 100644 index 00000000000..69f705f61ae --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/transitive-unreferenced-module.t @@ -0,0 +1,62 @@ +Per-module deps propagate across library boundaries: a module in +a transitively-reached library is a dep only if some referenced +entry module's ocamldep output names it. Editing an unreferenced +module in such a library does not invalidate consumers. + +Scenario: executable `main` references only `ModB` (from `libB`). +`libB` references only `ModA2` (from `libA`). `libA` is in `main`'s +compilation context only via transitive closure through `libB`. +Starting from `main`'s own ocamldep reads (`{ModB}`), the cross-lib +walk reads `libB/modB`'s ocamldep (names `ModA2`) and then +`libA/modA2`'s ocamldep (no new names). The tight set is +`{ModB, ModA2}`; `modA1` never enters it, so `main` depends only on +`libB/modB.cmi` and `libA/modA2.cmi`, not a glob over `libA`'s +objdir. + +Editing `modA1.mli` (which neither `main` nor `modB` references) +leaves `main`'s deps unchanged, so `main` is not rebuilt. + +See: https://github.com/ocaml/dune/pull/14116#issuecomment-4310263512 + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (library (name libA) (wrapped false) (modules modA1 modA2)) + > (library (name libB) (wrapped false) (modules modB) (libraries libA)) + > (executable (name main) (modules main) (libraries libB)) + > EOF + + $ cat > modA1.ml < let x = 42 + > EOF + $ cat > modA1.mli < val x : int + > EOF + $ cat > modA2.ml < let x = 43 + > EOF + $ cat > modB.ml < let x = ModA2.x + > EOF + $ cat > main.ml < let _ = ModB.x + > EOF + + $ dune build @check + +Edit modA1's interface. Neither `main` nor `modB` references `modA1`, +so `main` should not rebuild: + + $ cat > modA1.mli < val x : int + > val y : string + > EOF + $ cat > modA1.ml < let x = 42 + > let y = "hi" + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("dune__exe__Main"))] | length' + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/transitive.t b/test/blackbox-tests/test-cases/per-module-lib-deps/transitive.t index a673b3119f9..4b69eab38dc 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/transitive.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/transitive.t @@ -73,8 +73,8 @@ Change libB's interface: > let new_base_fn () = "new" > EOF -Independent is recompiled even though it doesn't reference libA or libB: +Independent is not recompiled because it doesn't reference libA or libB: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Independent"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t new file mode 100644 index 00000000000..31a16df8da1 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped-tight-deps.t @@ -0,0 +1,120 @@ +Baseline: consumer-module rebuild targets when individual modules +of an unwrapped dependency library change. + +This is an observational test. It records the rebuild targets for +a single consumer module C when each of three entry modules +(A1, A2, A3) of the dependency library [base] has its interface +edited. C references only A2. + +On current main, editing any one of A1/A2/A3 causes C to rebuild +because library-level dependency filtering (and, within that, +per-module tightening) is not yet in place: the consumer is +conservatively rebuilt whenever any entry module's cmi changes. +Work on https://github.com/ocaml/dune/issues/4572 is expected to +tighten this, at which point editing A1 or A3 leaves C untouched +and the emitted target list becomes empty. + +See: https://github.com/ocaml/dune/issues/4572 + + $ cat > dune-project < (lang dune 3.0) + > EOF + +base is an unwrapped library with three entry modules, each with an +explicit interface so signature changes propagate through .cmi files: + + $ mkdir base + $ cat > base/dune < (library (name base) (wrapped false)) + > EOF + $ cat > base/a1.ml < let v = 1 + > EOF + $ cat > base/a1.mli < val v : int + > EOF + $ cat > base/a2.ml < let v = 2 + > EOF + $ cat > base/a2.mli < val v : int + > EOF + $ cat > base/a3.ml < let v = 3 + > EOF + $ cat > base/a3.mli < val v : int + > EOF + +consumer has two modules. The module of interest, [c.ml], references +only [A2] from [base]. A second, unused module [d.ml] is present only +to keep [consumer] a multi-module stanza: dune skips ocamldep for +single-module stanzas with no library deps as an optimisation, and +the per-module-lib-deps filter depends on ocamldep output. Including +[d.ml] isolates this test from the skip-ocamldep optimisation so the +rebuild targets for [c] reflect only the per-module filter's work: + + $ mkdir consumer + $ cat > consumer/dune < (library (name consumer) (wrapped false) (libraries base)) + > EOF + $ cat > consumer/c.ml < let w = A2.v + > EOF + $ cat > consumer/d.ml < let _ = () + > EOF + + $ dune build @check + +Edit A1's interface — a module C does not reference — and record +the rebuild targets for C: + + $ cat > base/a1.mli < val v : int + > val extra : unit -> int + > EOF + $ cat > base/a1.ml < let v = 1 + > let extra () = 7 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))]' + [] + +Same for A3: + + $ cat > base/a3.mli < val v : int + > val other : string + > EOF + $ cat > base/a3.ml < let v = 3 + > let other = "hi" + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))]' + [] + +Edit A2's interface — the one module C does reference — and record +the rebuild targets (C must rebuild): + + $ cat > base/a2.mli < val v : int + > val new_fn : int -> int + > EOF + $ cat > base/a2.ml < let v = 2 + > let new_fn x = x + 1 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer/\\.consumer\\.objs/byte/c\\."))]' + [ + { + "target_files": [ + "_build/default/consumer/.consumer.objs/byte/c.cmi", + "_build/default/consumer/.consumer.objs/byte/c.cmo", + "_build/default/consumer/.consumer.objs/byte/c.cmt" + ] + } + ] diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped.t b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped.t index 908ce2e21b8..ea4aa1b697f 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/unwrapped.t @@ -1,8 +1,6 @@ -Baseline: library dependency recompilation for unwrapped libraries. - -When an unwrapped library module's interface changes, Dune currently recompiles -all modules in stanzas that depend on the library, even those referencing -different modules in the library. +Per-module filtering for unwrapped libraries: a consumer that references +only some modules of an unwrapped library must not be recompiled when +unreferenced modules in that library change. See: https://github.com/ocaml/dune/issues/4572 @@ -73,11 +71,11 @@ Change only helper.mli: > let new_helper s = s ^ "!" > EOF -Uses_utils is recompiled even though it only references Utils, not Helper: +Uses_utils references Utils only, not Helper, so it is not recompiled: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Uses_utils"))] | length' - 2 + 0 Change only utils.mli: @@ -90,8 +88,8 @@ Change only utils.mli: > let new_utils s = s ^ "?" > EOF -Uses_helper is recompiled even though it only references Helper, not Utils: +Uses_helper references Helper only, not Utils, so it is not recompiled: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Uses_helper"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-compat.t b/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-compat.t index adc34c6f62b..92fd82b0636 100644 --- a/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-compat.t +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-compat.t @@ -67,8 +67,8 @@ See: https://github.com/ocaml/dune/issues/4572 > let new_fn () = "hello" > EOF -Standalone is recompiled even though it doesn't reference Baselib: +Standalone is not recompiled because it doesn't reference Baselib: $ dune build ./main.exe $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("Standalone"))] | length' - 2 + 0 diff --git a/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-reexport-via-open-flag.t b/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-reexport-via-open-flag.t new file mode 100644 index 00000000000..041f2c23b41 --- /dev/null +++ b/test/blackbox-tests/test-cases/per-module-lib-deps/wrapped-reexport-via-open-flag.t @@ -0,0 +1,77 @@ +Observational baseline: a consumer reaches a dep library's modules +through a transparent alias declared inside a wrapped sibling +library, where the wrapped library is opened via the [-open] +compiler flag rather than named in source. On trunk, [consumer] +correctly rebuilds when the dep library's interface changes, +because the cctx-wide compile-rule deps cover every library in the +stanza's [(libraries ...)] closure. + +This is the structural shape of menhir's [base]/[middle] +arrangement: [base] re-exports [Vendored_pprint] through aliases, +[middle] uses [-open Base] in its compile flags, and modules of +[middle] reference [Vendored_pprint]'s contents through unqualified +names brought into scope by the open. Records the consumer's +rebuild count as a regression guard for changes that narrow +compile-rule deps per module. + +Structure: [lib_a] is unwrapped with module [Original_name]; +[lib_re_export] is wrapped with a hand-written wrapper containing +[module Re = Original_name]; [lib_b] depends on [lib_re_export] +and uses [-open Lib_re_export] in its flags; [consumer.ml] writes +[Re.x] without naming [lib_a] or [lib_re_export] in source. + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ cat > dune < (library (name lib_a) (wrapped false) (modules original_name)) + > (library + > (name lib_re_export) + > (modules lib_re_export some_inner) + > (libraries lib_a)) + > (library + > (name lib_b) + > (wrapped false) + > (modules consumer) + > (libraries lib_re_export) + > (flags (:standard -open Lib_re_export))) + > EOF + + $ cat > original_name.ml < let x = "hello" + > EOF + $ cat > original_name.mli < val x : string + > EOF + + $ cat > lib_re_export.ml < module Some_inner = Some_inner + > module Re = Original_name + > EOF + $ cat > some_inner.ml < let placeholder = () + > EOF + + $ cat > consumer.ml < let _ = Re.x + > EOF + + $ dune build @check + +Edit [lib_a]'s interface. [consumer] reaches [lib_a]'s +[Original_name] through the alias chain in the wrapped +[Lib_re_export] wrapper. The cctx-wide compile-rule deps include +[lib_a], so [consumer] rebuilds: + + $ cat > original_name.mli < val x : string + > val y : int + > EOF + $ cat > original_name.ml < let x = "hello" + > let y = 42 + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("consumer"))] | length' + 1 diff --git a/test/blackbox-tests/test-cases/pkg/pin-stanza/non-existent-branch.t b/test/blackbox-tests/test-cases/pkg/pin-stanza/non-existent-branch.t index 219f3dcbb50..222e7a60ecc 100644 --- a/test/blackbox-tests/test-cases/pkg/pin-stanza/non-existent-branch.t +++ b/test/blackbox-tests/test-cases/pkg/pin-stanza/non-existent-branch.t @@ -29,7 +29,7 @@ Reference a branch that does not exist in the pin: > EOF $ export BUILD_PATH_PREFIX_MAP="PWD=//$PWD:$BUILD_PATH_PREFIX_MAP" - $ dune pkg lock 2>&1 | dune_cmd subst '-\d+' '-eol' | dune_cmd delete '\^+' + $ dune pkg lock 2>&1 | dune_cmd subst 'characters 6-\d+' 'characters 6-eol' | dune_cmd delete '\^+' File "dune-project", line 3, characters 6-eol: 3 | (url "git+file:PWD/_repo#nonexistent-branch") revision "nonexistent-branch" not found in diff --git a/test/blackbox-tests/test-cases/reporting-of-cycles.t/run.t b/test/blackbox-tests/test-cases/reporting-of-cycles.t/run.t index e630fee88ae..e37394b1aaa 100644 --- a/test/blackbox-tests/test-cases/reporting-of-cycles.t/run.t +++ b/test/blackbox-tests/test-cases/reporting-of-cycles.t/run.t @@ -4,14 +4,6 @@ In all tests, we have a cycle that only becomes apparent after we start running things. In the past, the error was only reported during the second run of dune. - $ dune build @package-cycle - Error: Dependency cycle between: - alias a/.a-files - -> alias b/.b-files - -> alias a/.a-files - -> required by alias package-cycle in dune:1 - [1] - $ dune build @simple-repro-case Error: Dependency cycle between: _build/default/y diff --git a/test/blackbox-tests/test-cases/root-module/incremental-rebuild.t b/test/blackbox-tests/test-cases/root-module/incremental-rebuild.t new file mode 100644 index 00000000000..f8022143bf8 --- /dev/null +++ b/test/blackbox-tests/test-cases/root-module/incremental-rebuild.t @@ -0,0 +1,53 @@ +A consumer library that uses [(root_module ...)] to alias one of +its dependencies is rebuilt when the dependency's interface +changes, and not rebuilt when only the dependency's +implementation changes. This locks in the incremental-rebuild +property for [Root]-aliased dependencies that any future change +to dune's inter-library-dependency tracking must preserve. + + $ cat > dune-project < (lang dune 3.0) + > EOF + + $ mkdir lib1 lib2 + $ cat > lib1/dune < (library (name lib1)) + > EOF + $ cat > lib1/lib1.ml < let extra = 0 + > let greeting = "hello-" ^ string_of_int extra + > EOF + $ cat > lib1/lib1.mli < val greeting : string + > EOF + + $ cat > lib2/dune < (library (name lib2) (libraries lib1) (root_module root)) + > EOF + $ cat > lib2/lib2.ml < let () = print_endline Root.Lib1.greeting + > EOF + + $ dune build @check + +Editing only [lib1.mli] (the [.ml] is unchanged) changes +[lib1.cmi] and must invalidate [lib2]: + + $ cat > lib1/lib1.mli < val greeting : string + > val extra : int + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("\\.lib2\\.objs/"))] | length > 0' + true + +Editing only [lib1.ml] (no [.mli] change) leaves [lib1.cmi] +untouched, so [lib2] is not rebuilt: + + $ cat > lib1/lib1.ml < let extra = 1 + > let greeting = "hello-" ^ string_of_int extra + > EOF + $ dune build @check + $ dune trace cat | jq -s 'include "dune"; [.[] | targetsMatchingFilter(test("\\.lib2\\.objs/"))] | length' + 0 diff --git a/test/blackbox-tests/test-cases/stanzas/forbidden_libraries.t b/test/blackbox-tests/test-cases/stanzas/forbidden_libraries.t index 732c95aa1e9..ea689adf7af 100644 --- a/test/blackbox-tests/test-cases/stanzas/forbidden_libraries.t +++ b/test/blackbox-tests/test-cases/stanzas/forbidden_libraries.t @@ -24,6 +24,5 @@ Test the `forbidden_libraries` feature -> required by library "b" in _build/default -> required by library "c" in _build/default -> required by executable main in dune:5 - -> required by _build/default/.main.eobjs/native/dune__exe__Main.cmx -> required by _build/default/main.exe [1] diff --git a/test/blackbox-tests/test-cases/strict-package-deps.t b/test/blackbox-tests/test-cases/strict-package-deps.t index 2f724b4f08e..abc2a1ecea9 100644 --- a/test/blackbox-tests/test-cases/strict-package-deps.t +++ b/test/blackbox-tests/test-cases/strict-package-deps.t @@ -8,8 +8,8 @@ the package dependencies inferred by dune: > (package (name foo)) > EOF $ mkdir foo bar - $ touch foo/foo.ml - $ touch bar/bar.ml + $ echo 'let () = print_int Bar.value' >foo/foo.ml + $ echo 'let value = 1' >bar/bar.ml $ cat >foo/dune < (executable (public_name foo) (libraries bar) (package foo)) > EOF @@ -26,6 +26,9 @@ the package dependencies inferred by dune: $ cat >foo/dune < (library (public_name foo) (libraries bar)) > EOF + $ cat >foo/foo.ml < let use_bar () = Bar.value + > EOF $ dune build @install Error: Package foo is missing the following package dependencies - bar @@ -45,7 +48,9 @@ transitive deps. > (package (name bar) (depends baz)) > (package (name foo) (depends bar)) > EOF - $ touch baz.ml bar.ml foo.ml + $ echo 'let value = 1' >baz.ml + $ echo 'let chain = Baz.value' >bar.ml + $ echo 'let use = Bar.chain' >foo.ml $ cat >dune < (library (public_name baz) (modules baz)) > (library (public_name bar) (libraries baz) (modules bar)) diff --git a/test/blackbox-tests/test-cases/virtual-libraries/virtual-library-cycle-github2896.t b/test/blackbox-tests/test-cases/virtual-libraries/virtual-library-cycle-github2896.t index fe8ce3a61d7..9ebaa55d7d1 100644 --- a/test/blackbox-tests/test-cases/virtual-libraries/virtual-library-cycle-github2896.t +++ b/test/blackbox-tests/test-cases/virtual-libraries/virtual-library-cycle-github2896.t @@ -37,6 +37,5 @@ The implementation impl was built, but it's not usable: -> required by library "lib" in _build/default/lib -> required by library "impl" in _build/default/impl -> required by executable foo in dune:1 - -> required by _build/default/.foo.eobjs/native/dune__exe__Foo.cmx -> required by _build/default/foo.exe [1] diff --git a/test/blackbox-tests/test-cases/virtual-libraries/virtual-modules-excluded-by-modules-field.t b/test/blackbox-tests/test-cases/virtual-libraries/virtual-modules-excluded-by-modules-field.t index 9ef27f9a09f..66487db3ba1 100644 --- a/test/blackbox-tests/test-cases/virtual-libraries/virtual-modules-excluded-by-modules-field.t +++ b/test/blackbox-tests/test-cases/virtual-libraries/virtual-modules-excluded-by-modules-field.t @@ -76,6 +76,5 @@ This should be ignored if we are in vendored_dirs Error: No implementation found for virtual library "foo" in _build/default/src. -> required by executable bar in dune:3 - -> required by _build/default/.bar.eobjs/native/dune__exe__Bar.cmx -> required by _build/default/bar.exe [1] diff --git a/test/blackbox-tests/test-cases/virtual-libraries/vlib-wrong-default-impl.t/run.t b/test/blackbox-tests/test-cases/virtual-libraries/vlib-wrong-default-impl.t/run.t index 62b71b537ff..5a07430655d 100644 --- a/test/blackbox-tests/test-cases/virtual-libraries/vlib-wrong-default-impl.t/run.t +++ b/test/blackbox-tests/test-cases/virtual-libraries/vlib-wrong-default-impl.t/run.t @@ -4,7 +4,6 @@ library is not actually an implementation of the virtual library. $ dune build @default Error: "not_an_implem" is not an implementation of "vlibfoo". -> required by executable exe in exe/dune:2 - -> required by _build/default/exe/.exe.eobjs/native/dune__exe__Exe.cmx -> required by _build/default/exe/exe.exe -> required by alias exe/default in exe/dune:5 [1] diff --git a/test/blackbox-tests/test-cases/watching/multiple-errors-output.t/run.t b/test/blackbox-tests/test-cases/watching/multiple-errors-output.t/run.t index 79458d994fb..97fd398c369 100644 --- a/test/blackbox-tests/test-cases/watching/multiple-errors-output.t/run.t +++ b/test/blackbox-tests/test-cases/watching/multiple-errors-output.t/run.t @@ -15,10 +15,18 @@ We test the output of the watch mode client when we have multiple errors 1 | let y = "unknown variable" ^ what ^^^^ Unbound value what - Error: Build failed with 2 errors. + File "$TESTCASE_ROOT/src/foo.ml", line 1, characters 39-40: + 1 | let f = Bar.x + Baz.y + invalid_syntax : ? = ! + ^ + Syntax error + Error: Build failed with 3 errors. [1] $ stop_dune + File "src/foo.ml", line 1, characters 39-40: + 1 | let f = Bar.x + Baz.y + invalid_syntax : ? = ! + ^ + Error: Syntax error File "libs/bar.ml", line 1, characters 8-20: 1 | let y = "type error" + 3 ^^^^^^^^^^^^ @@ -28,4 +36,4 @@ We test the output of the watch mode client when we have multiple errors 1 | let y = "unknown variable" ^ what ^^^^ Error: Unbound value what - Had 2 errors, waiting for filesystem changes... + Had 3 errors, waiting for filesystem changes...