Monorepo management for opam overlays
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 284 lines 11 kB view raw
1type error = 2 | Multiple_versions of string * string list 3 | No_dev_repo of string 4 | Invalid_dev_repo of string * string 5 | Not_git_remote of string * string 6 | Parse_error of string * string 7 | Io_error of string 8 9let pp_error ppf = function 10 | Multiple_versions (name, versions) -> 11 Fmt.pf ppf "Package %s has multiple versions: %a" name 12 Fmt.(list ~sep:comma string) 13 versions 14 | No_dev_repo name -> Fmt.pf ppf "Package %s has no dev-repo field" name 15 | Invalid_dev_repo (name, url) -> 16 Fmt.pf ppf "Package %s has invalid dev-repo: %s" name url 17 | Not_git_remote (name, url) -> 18 Fmt.pf ppf "Package %s dev-repo is not a git URL: %s" name url 19 | Parse_error (path, msg) -> Fmt.pf ppf "Failed to parse %s: %s" path msg 20 | Io_error msg -> Fmt.pf ppf "I/O error: %s" msg 21 22let is_git_url url = 23 String.starts_with ~prefix:"git+" url 24 || String.starts_with ~prefix:"git://" url 25 || String.starts_with ~prefix:"git@" url 26 || String.ends_with ~suffix:".git" url 27 28let normalize_git_url url = 29 let url = 30 match String.starts_with ~prefix:"git+" url with 31 | true -> String.sub url 4 (String.length url - 4) 32 | false -> url 33 in 34 Uri.of_string url 35 36module OP = OpamParserTypes.FullPos 37 38let extract_string_value (v : OP.value) : string option = 39 match v.pelem with OP.String s -> Some s | _ -> None 40 41let find_dev_repo (items : OP.opamfile_item list) : string option = 42 List.find_map 43 (fun (item : OP.opamfile_item) -> 44 match item.pelem with 45 | OP.Variable (name, value) when name.pelem = "dev-repo" -> 46 extract_string_value value 47 | _ -> None) 48 items 49 50(** Extract package name from a dependency formula value. 51 Handles cases like: 52 - "pkgname" 53 - "pkgname" {>= "1.0"} 54 - "pkgname" {with-test} 55 Returns the package name if found. *) 56let rec extract_dep_name (v : OP.value) : string option = 57 match v.pelem with 58 | OP.String s -> Some s 59 | OP.Option (inner, _) -> extract_dep_name inner 60 | _ -> None 61 62(** Extract all dependency package names from a depends value. The depends field 63 is a list of package formulas. *) 64let extract_depends_list (v : OP.value) : string list = 65 match v.pelem with 66 | OP.List { pelem = items; _ } -> List.filter_map extract_dep_name items 67 | _ -> ( match extract_dep_name v with Some s -> [ s ] | None -> []) 68 69let find_depends (items : OP.opamfile_item list) : string list = 70 List.find_map 71 (fun (item : OP.opamfile_item) -> 72 match item.pelem with 73 | OP.Variable (name, value) when name.pelem = "depends" -> 74 Some (extract_depends_list value) 75 | _ -> None) 76 items 77 |> Option.value ~default:[] 78 79let find_synopsis (items : OP.opamfile_item list) : string option = 80 List.find_map 81 (fun (item : OP.opamfile_item) -> 82 match item.pelem with 83 | OP.Variable (name, value) when name.pelem = "synopsis" -> 84 extract_string_value value 85 | _ -> None) 86 items 87 88let parse_package_path (path : Fpath.t) : (string * string) option = 89 let segs = Fpath.segs path in 90 let rec find_after_packages = function 91 | [] -> None 92 | "packages" :: name :: name_version :: _ -> ( 93 match String.split_on_char '.' name_version with 94 | [ n; v ] when n = name -> Some (name, v) 95 | n :: rest when n = name -> Some (name, String.concat "." rest) 96 | _ -> None) 97 | _ :: rest -> find_after_packages rest 98 in 99 find_after_packages segs 100 101let load_package ~fs opam_file_path = 102 let path_str = Fpath.to_string opam_file_path in 103 match parse_package_path opam_file_path with 104 | None -> 105 Error (Parse_error (path_str, "Cannot determine package name/version")) 106 | Some (name, version) -> ( 107 try 108 let eio_path = Eio.Path.(fs / path_str) in 109 let content = Eio.Path.load eio_path in 110 let opamfile = OpamParser.FullPos.string content path_str in 111 match find_dev_repo opamfile.file_contents with 112 | None -> Error (No_dev_repo name) 113 | Some url -> 114 if not (is_git_url url) then Error (Not_git_remote (name, url)) 115 else 116 let dev_repo = normalize_git_url url in 117 let depends = find_depends opamfile.file_contents in 118 let synopsis = find_synopsis opamfile.file_contents in 119 Ok (Package.create ~name ~version ~dev_repo ~depends ?synopsis ()) 120 with 121 | Eio.Io _ as e -> Error (Io_error (Printexc.to_string e)) 122 | exn -> Error (Parse_error (path_str, Printexc.to_string exn))) 123 124let list_dirs ~fs path = 125 let eio_path = Eio.Path.(fs / Fpath.to_string path) in 126 try 127 Eio.Path.read_dir eio_path 128 |> List.filter (fun name -> 129 let child = Eio.Path.(eio_path / name) in 130 match Eio.Path.kind ~follow:false child with 131 | `Directory -> true 132 | _ -> false) 133 with Eio.Io _ -> [] 134 135let scan_all ~fs repo_path = 136 let packages_dir = Fpath.(repo_path / "packages") in 137 let package_names = list_dirs ~fs packages_dir in 138 let packages, errors = 139 List.fold_left 140 (fun (pkgs, errs) name -> 141 let pkg_dir = Fpath.(packages_dir / name) in 142 let versions = list_dirs ~fs pkg_dir in 143 match versions with 144 | [] -> (pkgs, errs) 145 | [ version_dir ] -> ( 146 let opam_path = Fpath.(pkg_dir / version_dir / "opam") in 147 match load_package ~fs opam_path with 148 | Ok pkg -> (pkg :: pkgs, errs) 149 | Error e -> (pkgs, e :: errs)) 150 | _ :: _ :: _ as vs -> (pkgs, Multiple_versions (name, vs) :: errs)) 151 ([], []) package_names 152 in 153 (List.rev packages, List.rev errors) 154 155let scan ~fs repo_path = 156 match scan_all ~fs repo_path with 157 | pkgs, [] -> Ok pkgs 158 | _, err :: _ -> Error err 159 160let validate_repo ~fs repo_path = 161 let _, errors = scan_all ~fs repo_path in 162 errors 163 164(** Scan a directory for .opam files and extract all dependencies. This is used 165 to find dependencies from monorepo subtree directories, where multiple .opam 166 files may exist that aren't in the opam overlay. *) 167let scan_opam_files_for_deps ~fs dir_path = 168 let eio_path = Eio.Path.(fs / Fpath.to_string dir_path) in 169 try 170 let files = Eio.Path.read_dir eio_path in 171 let opam_files = 172 List.filter (fun name -> Filename.check_suffix name ".opam") files 173 in 174 List.concat_map 175 (fun opam_file -> 176 let opam_path = Eio.Path.(eio_path / opam_file) in 177 try 178 let content = Eio.Path.load opam_path in 179 let opamfile = 180 OpamParser.FullPos.string content 181 (Fpath.to_string dir_path ^ "/" ^ opam_file) 182 in 183 find_depends opamfile.file_contents 184 with _ -> []) 185 opam_files 186 with Eio.Io _ -> [] 187 188(** Read the raw content of an opam file. *) 189let read_opam_file ~fs opam_file_path = 190 let eio_path = Eio.Path.(fs / Fpath.to_string opam_file_path) in 191 try Ok (Eio.Path.load eio_path) with Eio.Io _ as e -> Error (Io_error (Printexc.to_string e)) 192 193(** Replace dev-repo line in content. Looks for 'dev-repo: "..."' and replaces the URL. *) 194let replace_dev_repo_line content ~new_url = 195 let lines = String.split_on_char '\n' content in 196 let dev_repo_url = 197 if String.starts_with ~prefix:"git@" new_url then "git+" ^ new_url 198 else if String.starts_with ~prefix:"https://" new_url then "git+" ^ new_url 199 else if String.starts_with ~prefix:"git+" new_url then new_url 200 else "git+" ^ new_url 201 in 202 let lines = 203 List.map 204 (fun line -> 205 let trimmed = String.trim line in 206 if String.starts_with ~prefix:"dev-repo:" trimmed then 207 Printf.sprintf {|dev-repo: "%s"|} dev_repo_url 208 else line) 209 lines 210 in 211 String.concat "\n" lines 212 213(** Replace url { src: "..." } section in content. *) 214let replace_url_section content ~new_url = 215 let url_src = 216 let base = 217 if String.starts_with ~prefix:"git@" new_url then "git+" ^ new_url 218 else if String.starts_with ~prefix:"https://" new_url then "git+" ^ new_url 219 else if String.starts_with ~prefix:"git+" new_url then new_url 220 else "git+" ^ new_url 221 in 222 base ^ "#main" 223 in 224 (* Simple state machine to find and replace url { src: "..." } block *) 225 let lines = String.split_on_char '\n' content in 226 let rec process lines in_url_block acc = 227 match lines with 228 | [] -> List.rev acc 229 | line :: rest -> 230 let trimmed = String.trim line in 231 if in_url_block then 232 (* We're inside url { ... }, skip until we see } *) 233 if String.starts_with ~prefix:"}" trimmed then 234 (* End of url block - output our replacement *) 235 let replacement = 236 [ "url {"; Printf.sprintf {| src: "%s"|} url_src; "}" ] 237 in 238 process rest false (List.rev_append replacement acc) 239 else 240 (* Skip this line, it's part of the old url block *) 241 process rest true acc 242 else if trimmed = "url {" || String.starts_with ~prefix:"url {" trimmed then 243 (* Start of url block *) 244 if String.ends_with ~suffix:"}" trimmed then 245 (* Single-line url block *) 246 let replacement = 247 [ "url {"; Printf.sprintf {| src: "%s"|} url_src; "}" ] 248 in 249 process rest false (List.rev_append replacement acc) 250 else process rest true acc 251 else process rest false (line :: acc) 252 in 253 String.concat "\n" (process lines false []) 254 255(** Replace the dev-repo and url fields in an opam file content with a new git URL. 256 The new URL should be a plain git URL (e.g., "git@github.com:user/repo.git"). *) 257let replace_dev_repo_url content ~new_url = 258 let content = replace_dev_repo_line content ~new_url in 259 let content = replace_url_section content ~new_url in 260 content 261 262(** Write an opam package to the opam-repo overlay. 263 Creates the directory structure: packages/<name>/<name.version>/opam *) 264let write_package ~fs ~repo_path ~name ~version ~content = 265 let pkg_dir = Fpath.(repo_path / "packages" / name / (name ^ "." ^ version)) in 266 let opam_path = Fpath.(pkg_dir / "opam") in 267 let eio_pkg_dir = Eio.Path.(fs / Fpath.to_string pkg_dir) in 268 let eio_opam_path = Eio.Path.(fs / Fpath.to_string opam_path) in 269 try 270 (* Create directory structure *) 271 Eio.Path.mkdirs ~perm:0o755 eio_pkg_dir; 272 (* Write opam file *) 273 Eio.Path.save ~create:(`Or_truncate 0o644) eio_opam_path content; 274 Ok () 275 with Eio.Io _ as e -> Error (Io_error (Printexc.to_string e)) 276 277(** Check if a package exists in the opam-repo. *) 278let package_exists ~fs ~repo_path ~name = 279 let pkg_dir = Fpath.(repo_path / "packages" / name) in 280 let eio_path = Eio.Path.(fs / Fpath.to_string pkg_dir) in 281 match Eio.Path.kind ~follow:true eio_path with 282 | `Directory -> true 283 | _ -> false 284 | exception _ -> false