···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(* ---- Variable expansion ---- *)
77+88+module Var = struct
99+ type t = { name : string; default : string option }
1010+1111+ let v ?default name = { name; default }
1212+ let name v = v.name
1313+ let default v = v.default
1414+1515+ let pp ppf v =
1616+ match v.default with
1717+ | None -> Fmt.pf ppf "%%{%s}" v.name
1818+ | Some d -> Fmt.pf ppf "%%{%s:%s}" v.name d
1919+2020+ let to_string v = Fmt.str "%a" pp v
2121+2222+ let parse_at s pos =
2323+ let len = String.length s in
2424+ if pos + 2 >= len || s.[pos] <> '%' || s.[pos + 1] <> '{' then None
2525+ else
2626+ let start = pos + 2 in
2727+ let rec find_end i =
2828+ if i >= len then None
2929+ else if s.[i] = '}' then Some i
3030+ else find_end (i + 1)
3131+ in
3232+ match find_end start with
3333+ | None -> None
3434+ | Some end_pos ->
3535+ let inner = String.sub s start (end_pos - start) in
3636+ let var =
3737+ match String.index_opt inner ':' with
3838+ | None -> { name = inner; default = None }
3939+ | Some colon ->
4040+ let name = String.sub inner 0 colon in
4141+ let default =
4242+ String.sub inner (colon + 1) (String.length inner - colon - 1)
4343+ in
4444+ { name; default = Some default }
4545+ in
4646+ Some (var, end_pos + 1)
4747+end
4848+4949+type segment = Text of string | Variable of Var.t
5050+type text = segment list
5151+5252+let text_of_string s =
5353+ let len = String.length s in
5454+ let rec loop acc pos =
5555+ if pos >= len then List.rev acc
5656+ else
5757+ match Var.parse_at s pos with
5858+ | Some (var, next_pos) -> loop (Variable var :: acc) next_pos
5959+ | None ->
6060+ let rec find_next i =
6161+ if i >= len then i
6262+ else if s.[i] = '%' && i + 1 < len && s.[i + 1] = '{' then i
6363+ else find_next (i + 1)
6464+ in
6565+ let next = find_next pos in
6666+ if next - pos = 0 then loop acc next
6767+ else
6868+ let text = String.sub s pos (next - pos) in
6969+ loop (Text text :: acc) next
7070+ in
7171+ loop [] 0
7272+7373+let pp_segment ppf = function
7474+ | Text s -> Fmt.string ppf s
7575+ | Variable v -> Var.pp ppf v
7676+7777+let pp_text ppf segments = Fmt.(list ~sep:nop pp_segment) ppf segments
7878+let text_to_string segments = Fmt.str "%a" pp_text segments
7979+8080+let expand_text ~env segments =
8181+ let buf = Buffer.create 64 in
8282+ List.iter
8383+ (function
8484+ | Text s -> Buffer.add_string buf s
8585+ | Variable v -> (
8686+ match env v.name with
8787+ | Some value -> Buffer.add_string buf value
8888+ | None -> (
8989+ match v.default with
9090+ | Some d -> Buffer.add_string buf d
9191+ | None -> Buffer.add_string buf (Var.to_string v))))
9292+ segments;
9393+ Buffer.contents buf
9494+9595+let expand_atom ~env s =
9696+ let segments = text_of_string s in
9797+ expand_text ~env segments
9898+9999+let rec expand ~env = function
100100+ | Sexp.Atom s -> Sexp.Atom (expand_atom ~env s)
101101+ | Sexp.List l -> Sexp.List (List.map (expand ~env) l)
102102+103103+let has_variables s =
104104+ let len = String.length s in
105105+ let rec find_close i =
106106+ if i >= len then false else if s.[i] = '}' then true else find_close (i + 1)
107107+ in
108108+ let rec loop i =
109109+ if i + 1 >= len then false
110110+ else if s.[i] = '%' && s.[i + 1] = '{' && find_close (i + 2) then true
111111+ else loop (i + 1)
112112+ in
113113+ loop 0
114114+115115+let rec variables = function
116116+ | Sexp.Atom s ->
117117+ let segments = text_of_string s in
118118+ List.filter_map
119119+ (function Text _ -> None | Variable v -> Some v.name)
120120+ segments
121121+ | Sexp.List l -> List.concat_map variables l
122122+123123+(* ---- Dune-style pretty printing ---- *)
124124+125125+let needs_quoting s =
126126+ String.length s = 0
127127+ || String.exists
128128+ (fun c ->
129129+ match c with
130130+ | ' ' | '\t' | '\n' | '\r' | '(' | ')' | '"' | ';' -> true
131131+ | _ -> false)
132132+ s
133133+134134+let pp_quoted_atom ppf s =
135135+ Fmt.char ppf '"';
136136+ String.iter
137137+ (fun c ->
138138+ match c with
139139+ | '"' -> Fmt.string ppf "\\\""
140140+ | '\\' -> Fmt.string ppf "\\\\"
141141+ | '\n' -> Fmt.string ppf "\\n"
142142+ | '\r' -> Fmt.string ppf "\\r"
143143+ | '\t' -> Fmt.string ppf "\\t"
144144+ | c -> Fmt.char ppf c)
145145+ s;
146146+ Fmt.char ppf '"'
147147+148148+let pp_atom ppf s =
149149+ if needs_quoting s then pp_quoted_atom ppf s else Fmt.string ppf s
150150+151151+let pp_dune ppf sexp =
152152+ let rec pp_sexp indent ppf = function
153153+ | Sexp.Atom s -> pp_atom ppf s
154154+ | Sexp.List [] -> Fmt.string ppf "()"
155155+ | Sexp.List ((Sexp.Atom _ as head) :: rest) when List.length rest <= 3 ->
156156+ Fmt.char ppf '(';
157157+ pp_sexp indent ppf head;
158158+ List.iter
159159+ (fun item ->
160160+ Fmt.char ppf ' ';
161161+ pp_sexp indent ppf item)
162162+ rest;
163163+ Fmt.char ppf ')'
164164+ | Sexp.List ((Sexp.Atom _ as head) :: rest) ->
165165+ Fmt.char ppf '(';
166166+ pp_sexp indent ppf head;
167167+ let new_indent = indent + 1 in
168168+ List.iter
169169+ (fun item ->
170170+ Fmt.char ppf '\n';
171171+ Fmt.string ppf (String.make new_indent ' ');
172172+ pp_sexp new_indent ppf item)
173173+ rest;
174174+ Fmt.char ppf ')'
175175+ | Sexp.List items ->
176176+ Fmt.char ppf '(';
177177+ let new_indent = indent + 1 in
178178+ (match items with
179179+ | [] -> ()
180180+ | first :: rest ->
181181+ pp_sexp new_indent ppf first;
182182+ List.iter
183183+ (fun item ->
184184+ Fmt.char ppf '\n';
185185+ Fmt.string ppf (String.make new_indent ' ');
186186+ pp_sexp new_indent ppf item)
187187+ rest);
188188+ Fmt.char ppf ')'
189189+ in
190190+ pp_sexp 0 ppf sexp
191191+192192+let to_string_dune sexp = Fmt.str "%a" pp_dune sexp
193193+194194+let pp_dune_file ppf sexps =
195195+ let rec loop = function
196196+ | [] -> ()
197197+ | [ sexp ] -> pp_dune ppf sexp
198198+ | sexp :: rest ->
199199+ pp_dune ppf sexp;
200200+ Fmt.string ppf "\n\n";
201201+ loop rest
202202+ in
203203+ loop sexps
204204+205205+let to_string_dune_file sexps = Fmt.str "%a@." pp_dune_file sexps
206206+207207+(* ---- Field accessors ---- *)
208208+209209+let field name sexp =
210210+ match sexp with
211211+ | Sexp.List items ->
212212+ List.find_map
213213+ (function
214214+ | Sexp.List (Sexp.Atom n :: rest) when String.equal n name -> (
215215+ match rest with [ v ] -> Some v | _ -> Some (Sexp.List rest))
216216+ | _ -> None)
217217+ items
218218+ | Sexp.Atom _ -> None
219219+220220+let fields name sexp =
221221+ match sexp with
222222+ | Sexp.List items ->
223223+ List.filter_map
224224+ (function
225225+ | Sexp.List (Sexp.Atom n :: rest) when String.equal n name -> (
226226+ match rest with [ v ] -> Some v | _ -> Some (Sexp.List rest))
227227+ | _ -> None)
228228+ items
229229+ | Sexp.Atom _ -> []
230230+231231+let field_atom name sexp =
232232+ match field name sexp with Some (Sexp.Atom s) -> Some s | _ -> None
233233+234234+let field_list name sexp =
235235+ match field name sexp with Some (Sexp.List l) -> Some l | _ -> None
236236+237237+let field_atoms name sexp =
238238+ match field_list name sexp with
239239+ | Some items ->
240240+ Some
241241+ (List.filter_map (function Sexp.Atom s -> Some s | _ -> None) items)
242242+ | None -> None
243243+244244+let set_field name value sexp =
245245+ match sexp with
246246+ | Sexp.List (head :: items) ->
247247+ let field = Sexp.List [ Sexp.Atom name; value ] in
248248+ let found = ref false in
249249+ let items =
250250+ List.map
251251+ (function
252252+ | Sexp.List (Sexp.Atom n :: _) when String.equal n name ->
253253+ found := true;
254254+ field
255255+ | item -> item)
256256+ items
257257+ in
258258+ let items = if !found then items else items @ [ field ] in
259259+ Sexp.List (head :: items)
260260+ | _ -> sexp
261261+262262+let remove_field name sexp =
263263+ match sexp with
264264+ | Sexp.List (head :: items) ->
265265+ let items =
266266+ List.filter
267267+ (function
268268+ | Sexp.List (Sexp.Atom n :: _) -> not (String.equal n name)
269269+ | _ -> true)
270270+ items
271271+ in
272272+ Sexp.List (head :: items)
273273+ | _ -> sexp
274274+275275+(* ---- Stanza types ---- *)
276276+277277+type stanza_kind =
278278+ | Library
279279+ | Executable
280280+ | Executables
281281+ | Test
282282+ | Tests
283283+ | Rule
284284+ | Install
285285+ | Alias
286286+ | Env
287287+ | Include
288288+ | Other of string
289289+290290+let stanza_kind_of_string = function
291291+ | "library" -> Library
292292+ | "executable" -> Executable
293293+ | "executables" -> Executables
294294+ | "test" -> Test
295295+ | "tests" -> Tests
296296+ | "rule" -> Rule
297297+ | "install" -> Install
298298+ | "alias" -> Alias
299299+ | "env" -> Env
300300+ | "include" -> Include
301301+ | s -> Other s
302302+303303+let stanza_kind_to_string = function
304304+ | Library -> "library"
305305+ | Executable -> "executable"
306306+ | Executables -> "executables"
307307+ | Test -> "test"
308308+ | Tests -> "tests"
309309+ | Rule -> "rule"
310310+ | Install -> "install"
311311+ | Alias -> "alias"
312312+ | Env -> "env"
313313+ | Include -> "include"
314314+ | Other s -> s
315315+316316+let stanza_kind sexp =
317317+ match sexp with
318318+ | Sexp.List (Sexp.Atom name :: _) -> Some (stanza_kind_of_string name)
319319+ | _ -> None
320320+321321+(* ---- Common stanza builders ---- *)
322322+323323+let library ?public_name ?libraries ?preprocess name =
324324+ let fields = [ Sexp.List [ Sexp.Atom "name"; Sexp.Atom name ] ] in
325325+ let fields =
326326+ match public_name with
327327+ | Some pn ->
328328+ fields @ [ Sexp.List [ Sexp.Atom "public_name"; Sexp.Atom pn ] ]
329329+ | None -> fields
330330+ in
331331+ let fields =
332332+ match libraries with
333333+ | Some libs when libs <> [] ->
334334+ fields
335335+ @ [
336336+ Sexp.List
337337+ (Sexp.Atom "libraries" :: List.map (fun s -> Sexp.Atom s) libs);
338338+ ]
339339+ | _ -> fields
340340+ in
341341+ let fields =
342342+ match preprocess with
343343+ | Some pp -> fields @ [ Sexp.List [ Sexp.Atom "preprocess"; pp ] ]
344344+ | None -> fields
345345+ in
346346+ Sexp.List (Sexp.Atom "library" :: fields)
347347+348348+let executable ?public_name ?libraries ?preprocess name =
349349+ let fields = [ Sexp.List [ Sexp.Atom "name"; Sexp.Atom name ] ] in
350350+ let fields =
351351+ match public_name with
352352+ | Some pn ->
353353+ fields @ [ Sexp.List [ Sexp.Atom "public_name"; Sexp.Atom pn ] ]
354354+ | None -> fields
355355+ in
356356+ let fields =
357357+ match libraries with
358358+ | Some libs when libs <> [] ->
359359+ fields
360360+ @ [
361361+ Sexp.List
362362+ (Sexp.Atom "libraries" :: List.map (fun s -> Sexp.Atom s) libs);
363363+ ]
364364+ | _ -> fields
365365+ in
366366+ let fields =
367367+ match preprocess with
368368+ | Some pp -> fields @ [ Sexp.List [ Sexp.Atom "preprocess"; pp ] ]
369369+ | None -> fields
370370+ in
371371+ Sexp.List (Sexp.Atom "executable" :: fields)
372372+373373+let test ?libraries ?modules name =
374374+ let fields = [ Sexp.List [ Sexp.Atom "name"; Sexp.Atom name ] ] in
375375+ let fields =
376376+ match modules with
377377+ | Some mods when mods <> [] ->
378378+ fields
379379+ @ [
380380+ Sexp.List
381381+ (Sexp.Atom "modules" :: List.map (fun s -> Sexp.Atom s) mods);
382382+ ]
383383+ | _ -> fields
384384+ in
385385+ let fields =
386386+ match libraries with
387387+ | Some libs when libs <> [] ->
388388+ fields
389389+ @ [
390390+ Sexp.List
391391+ (Sexp.Atom "libraries" :: List.map (fun s -> Sexp.Atom s) libs);
392392+ ]
393393+ | _ -> fields
394394+ in
395395+ Sexp.List (Sexp.Atom "test" :: fields)
396396+397397+(* ---- dune-project codec ---- *)
398398+399399+module Project = struct
400400+ type t = {
401401+ lang : string * string;
402402+ name : string option;
403403+ version : string option;
404404+ generate_opam_files : bool option;
405405+ license : string option;
406406+ authors : string list;
407407+ maintainers : string list;
408408+ source : string option;
409409+ bug_reports : string option;
410410+ homepage : string option;
411411+ documentation : string option;
412412+ packages : Sexp.t list;
413413+ other : Sexp.t list;
414414+ }
415415+416416+ let empty =
417417+ {
418418+ lang = ("dune", "3.0");
419419+ name = None;
420420+ version = None;
421421+ generate_opam_files = None;
422422+ license = None;
423423+ authors = [];
424424+ maintainers = [];
425425+ source = None;
426426+ bug_reports = None;
427427+ homepage = None;
428428+ documentation = None;
429429+ packages = [];
430430+ other = [];
431431+ }
432432+433433+ let make ?(dune_version = "3.0") ?name ?version ?generate_opam_files ?license
434434+ ?(authors = []) ?(maintainers = []) ?source ?bug_reports ?homepage
435435+ ?documentation ?(packages = []) () =
436436+ {
437437+ lang = ("dune", dune_version);
438438+ name;
439439+ version;
440440+ generate_opam_files;
441441+ license;
442442+ authors;
443443+ maintainers;
444444+ source;
445445+ bug_reports;
446446+ homepage;
447447+ documentation;
448448+ packages;
449449+ other = [];
450450+ }
451451+452452+ let parse sexps =
453453+ let rec loop acc = function
454454+ | [] -> acc
455455+ | Sexp.List [ Sexp.Atom "lang"; Sexp.Atom lang; Sexp.Atom version ]
456456+ :: rest ->
457457+ loop { acc with lang = (lang, version) } rest
458458+ | Sexp.List [ Sexp.Atom "name"; Sexp.Atom name ] :: rest ->
459459+ loop { acc with name = Some name } rest
460460+ | Sexp.List [ Sexp.Atom "version"; Sexp.Atom version ] :: rest ->
461461+ loop { acc with version = Some version } rest
462462+ | Sexp.List [ Sexp.Atom "generate_opam_files"; Sexp.Atom b ] :: rest ->
463463+ let b =
464464+ match String.lowercase_ascii b with
465465+ | "true" -> Some true
466466+ | "false" -> Some false
467467+ | _ -> None
468468+ in
469469+ loop { acc with generate_opam_files = b } rest
470470+ | Sexp.List [ Sexp.Atom "license"; Sexp.Atom license ] :: rest ->
471471+ loop { acc with license = Some license } rest
472472+ | Sexp.List (Sexp.Atom "authors" :: authors) :: rest ->
473473+ let authors =
474474+ List.filter_map
475475+ (function Sexp.Atom s -> Some s | _ -> None)
476476+ authors
477477+ in
478478+ loop { acc with authors } rest
479479+ | Sexp.List (Sexp.Atom "maintainers" :: maintainers) :: rest ->
480480+ let maintainers =
481481+ List.filter_map
482482+ (function Sexp.Atom s -> Some s | _ -> None)
483483+ maintainers
484484+ in
485485+ loop { acc with maintainers } rest
486486+ | Sexp.List [ Sexp.Atom "source"; source ] :: rest ->
487487+ let source =
488488+ match source with
489489+ | Sexp.Atom s -> Some s
490490+ | Sexp.List [ Sexp.Atom "github"; Sexp.Atom repo ] ->
491491+ Some ("github:" ^ repo)
492492+ | Sexp.List [ Sexp.Atom "uri"; Sexp.Atom uri ] -> Some uri
493493+ | _ -> None
494494+ in
495495+ loop { acc with source } rest
496496+ | Sexp.List [ Sexp.Atom "bug_reports"; Sexp.Atom url ] :: rest ->
497497+ loop { acc with bug_reports = Some url } rest
498498+ | Sexp.List [ Sexp.Atom "homepage"; Sexp.Atom url ] :: rest ->
499499+ loop { acc with homepage = Some url } rest
500500+ | Sexp.List [ Sexp.Atom "documentation"; Sexp.Atom url ] :: rest ->
501501+ loop { acc with documentation = Some url } rest
502502+ | (Sexp.List (Sexp.Atom "package" :: _) as pkg) :: rest ->
503503+ loop { acc with packages = acc.packages @ [ pkg ] } rest
504504+ | other :: rest -> loop { acc with other = acc.other @ [ other ] } rest
505505+ in
506506+ loop empty sexps
507507+508508+ let field name value = Sexp.List [ Sexp.Atom name; Sexp.Atom value ]
509509+ let field_opt name = function Some v -> [ field name v ] | None -> []
510510+511511+ let field_list name values =
512512+ if values = [] then []
513513+ else
514514+ [ Sexp.List (Sexp.Atom name :: List.map (fun s -> Sexp.Atom s) values) ]
515515+516516+ let source_sexp = function
517517+ | Some s when String.starts_with ~prefix:"github:" s ->
518518+ let repo = String.sub s 7 (String.length s - 7) in
519519+ [
520520+ Sexp.List
521521+ [
522522+ Sexp.Atom "source";
523523+ Sexp.List [ Sexp.Atom "github"; Sexp.Atom repo ];
524524+ ];
525525+ ]
526526+ | Some uri ->
527527+ [
528528+ Sexp.List
529529+ [ Sexp.Atom "source"; Sexp.List [ Sexp.Atom "uri"; Sexp.Atom uri ] ];
530530+ ]
531531+ | None -> []
532532+533533+ let to_sexps t =
534534+ let lang, version = t.lang in
535535+ [ Sexp.List [ Sexp.Atom "lang"; Sexp.Atom lang; Sexp.Atom version ] ]
536536+ @ field_opt "name" t.name
537537+ @ field_opt "version" t.version
538538+ @ (match t.generate_opam_files with
539539+ | Some b ->
540540+ [ field "generate_opam_files" (if b then "true" else "false") ]
541541+ | None -> [])
542542+ @ field_opt "license" t.license
543543+ @ field_list "authors" t.authors
544544+ @ field_list "maintainers" t.maintainers
545545+ @ source_sexp t.source
546546+ @ field_opt "bug_reports" t.bug_reports
547547+ @ field_opt "homepage" t.homepage
548548+ @ field_opt "documentation" t.documentation
549549+ @ t.packages @ t.other
550550+551551+ let to_string t = to_string_dune_file (to_sexps t)
552552+end
553553+554554+(* ---- dune-workspace codec ---- *)
555555+556556+module Workspace = struct
557557+ type context_kind = Default | Opam of { switch : string } | Other of Sexp.t
558558+ type context = { name : string option; kind : context_kind }
559559+560560+ type t = {
561561+ lang : string * string;
562562+ contexts : context list;
563563+ env : Sexp.t option;
564564+ other : Sexp.t list;
565565+ }
566566+567567+ let empty = { lang = ("dune", "3.0"); contexts = []; env = None; other = [] }
568568+569569+ let parse sexps =
570570+ let rec loop acc = function
571571+ | [] -> acc
572572+ | Sexp.List [ Sexp.Atom "lang"; Sexp.Atom lang; Sexp.Atom version ]
573573+ :: rest ->
574574+ loop { acc with lang = (lang, version) } rest
575575+ | Sexp.List [ Sexp.Atom "context"; Sexp.Atom "default" ] :: rest ->
576576+ loop
577577+ {
578578+ acc with
579579+ contexts = acc.contexts @ [ { name = None; kind = Default } ];
580580+ }
581581+ rest
582582+ | Sexp.List [ Sexp.Atom "context"; Sexp.List ctx_fields ] :: rest ->
583583+ let name = field_atom "name" (Sexp.List ctx_fields) in
584584+ let kind =
585585+ match field "opam" (Sexp.List ctx_fields) with
586586+ | Some (Sexp.List opam_fields) -> (
587587+ match field_atom "switch" (Sexp.List opam_fields) with
588588+ | Some switch -> Opam { switch }
589589+ | None -> Other (Sexp.List ctx_fields))
590590+ | _ -> (
591591+ match field_atom "default" (Sexp.List ctx_fields) with
592592+ | Some _ -> Default
593593+ | None -> Other (Sexp.List ctx_fields))
594594+ in
595595+ loop { acc with contexts = acc.contexts @ [ { name; kind } ] } rest
596596+ | Sexp.List (Sexp.Atom "env" :: env_fields) :: rest ->
597597+ loop { acc with env = Some (Sexp.List env_fields) } rest
598598+ | other :: rest -> loop { acc with other = acc.other @ [ other ] } rest
599599+ in
600600+ loop empty sexps
601601+602602+ let to_sexps t =
603603+ let sexps = [] in
604604+ let sexps =
605605+ let lang, version = t.lang in
606606+ Sexp.List [ Sexp.Atom "lang"; Sexp.Atom lang; Sexp.Atom version ] :: sexps
607607+ in
608608+ let sexps =
609609+ sexps
610610+ @ List.map
611611+ (fun ctx ->
612612+ let ctx_sexp =
613613+ match ctx.kind with
614614+ | Default -> Sexp.Atom "default"
615615+ | Opam { switch } ->
616616+ let fields =
617617+ [
618618+ Sexp.List
619619+ [
620620+ Sexp.Atom "opam";
621621+ Sexp.List [ Sexp.Atom "switch"; Sexp.Atom switch ];
622622+ ];
623623+ ]
624624+ in
625625+ let fields =
626626+ match ctx.name with
627627+ | Some name ->
628628+ Sexp.List [ Sexp.Atom "name"; Sexp.Atom name ] :: fields
629629+ | None -> fields
630630+ in
631631+ Sexp.List fields
632632+ | Other sexp -> sexp
633633+ in
634634+ Sexp.List [ Sexp.Atom "context"; ctx_sexp ])
635635+ t.contexts
636636+ in
637637+ let sexps =
638638+ match t.env with
639639+ | Some (Sexp.List env_fields) ->
640640+ sexps @ [ Sexp.List (Sexp.Atom "env" :: env_fields) ]
641641+ | Some env -> sexps @ [ Sexp.List [ Sexp.Atom "env"; env ] ]
642642+ | None -> sexps
643643+ in
644644+ sexps @ t.other
645645+646646+ let to_string t = to_string_dune_file (to_sexps t)
647647+end
648648+649649+(* ---- dune file codec ---- *)
650650+651651+module File = struct
652652+ type t = Sexp.t list
653653+654654+ let parse = Fun.id
655655+ let to_sexps = Fun.id
656656+ let to_string = to_string_dune_file
657657+658658+ let find_stanza kind stanzas =
659659+ List.find_opt (fun s -> stanza_kind s = Some kind) stanzas
660660+661661+ let find_stanzas kind stanzas =
662662+ List.filter (fun s -> stanza_kind s = Some kind) stanzas
663663+664664+ let library_names stanzas =
665665+ find_stanzas Library stanzas |> List.filter_map (field_atom "name")
666666+667667+ let private_library_names stanzas =
668668+ find_stanzas Library stanzas
669669+ |> List.filter_map (fun s ->
670670+ match (field_atom "name" s, field_atom "public_name" s) with
671671+ | Some n, None -> Some n
672672+ | _ -> None)
673673+674674+ let executable_names stanzas =
675675+ find_stanzas Executable stanzas @ find_stanzas Executables stanzas
676676+ |> List.filter_map (fun s ->
677677+ match field_atom "name" s with
678678+ | Some n -> Some n
679679+ | None -> (
680680+ match field_atoms "names" s with
681681+ | Some names -> Some (String.concat "," names)
682682+ | None -> None))
683683+684684+ let test_names stanzas =
685685+ find_stanzas Test stanzas @ find_stanzas Tests stanzas
686686+ |> List.filter_map (fun s ->
687687+ match field_atom "name" s with
688688+ | Some n -> Some n
689689+ | None -> (
690690+ match field_atoms "names" s with
691691+ | Some names -> Some (String.concat "," names)
692692+ | None -> None))
693693+end
694694+695695+module Package = struct
696696+ module Library = struct
697697+ type t = {
698698+ name : string;
699699+ main_module_name : string option;
700700+ modules : string list;
701701+ implements : string option;
702702+ }
703703+704704+ let capitalize s =
705705+ if s = "" then s
706706+ else
707707+ String.uppercase_ascii (String.sub s 0 1)
708708+ ^ String.sub s 1 (String.length s - 1)
709709+710710+ let rec gather_obj_names acc = function
711711+ | Sexp.List (Sexp.Atom "module" :: fs) as m ->
712712+ let kind = field_atom "kind" m in
713713+ if kind = Some "alias" then List.fold_left gather_obj_names acc fs
714714+ else
715715+ let acc =
716716+ match field_atom "obj_name" m with
717717+ | Some n -> capitalize n :: acc
718718+ | None -> acc
719719+ in
720720+ List.fold_left gather_obj_names acc fs
721721+ | Sexp.List xs -> List.fold_left gather_obj_names acc xs
722722+ | _ -> acc
723723+724724+ let modules_codec : string list Sexp.Codec.t =
725725+ Sexp.Codec.map ~kind:"modules"
726726+ ~dec:(fun v -> List.rev (gather_obj_names [] v))
727727+ ~enc:(fun _ -> Sexp.List [])
728728+ Sexp.Codec.value
729729+730730+ let record_codec : t Sexp.Codec.t =
731731+ Sexp.Codec.Record.(
732732+ obj ~kind:"library" (fun name main_module_name modules implements ->
733733+ let modules =
734734+ match main_module_name with
735735+ | Some m when not (List.mem m modules) -> m :: modules
736736+ | _ -> modules
737737+ in
738738+ { name; main_module_name; modules; implements })
739739+ |> mem "name" Sexp.Codec.string ~enc:(fun l -> l.name)
740740+ |> opt_mem "main_module_name" Sexp.Codec.string ~enc:(fun l ->
741741+ l.main_module_name)
742742+ |> mem "modules" modules_codec ~dec_absent:[] ~enc:(fun l -> l.modules)
743743+ |> opt_mem "implements" Sexp.Codec.string ~enc:(fun l -> l.implements)
744744+ |> skip_unknown |> finish)
745745+746746+ let codec : t Sexp.Codec.t =
747747+ Sexp.Codec.Variant.(
748748+ variant ~kind:"library"
749749+ [ case "library" record_codec (fun l -> l) (fun l -> Some l) ])
750750+ end
751751+end
752752+753753+module Lib_index = struct
754754+ module String_set = Set.Make (String)
755755+756756+ type t = {
757757+ modules : (string, String_set.t) Hashtbl.t;
758758+ virtual_impls : (string, unit) Hashtbl.t;
759759+ }
760760+761761+ let empty () =
762762+ { modules = Hashtbl.create 256; virtual_impls = Hashtbl.create 16 }
763763+764764+ let merge_modules t name mods =
765765+ if mods = [] then ()
766766+ else
767767+ let existing =
768768+ try Hashtbl.find t.modules name with Not_found -> String_set.empty
769769+ in
770770+ Hashtbl.replace t.modules name
771771+ (String_set.union existing (String_set.of_list mods))
772772+773773+ (* Stream stanzas one at a time through Library.codec — no intermediate
774774+ [Library.t list]. Non-library stanzas (e.g. [(lang dune 3.0)]) and any
775775+ that fail to decode are skipped silently; this is metadata discovery,
776776+ not validation. *)
777777+ let add_dune_package t content =
778778+ (match Sexp.Value.parse_string_many content with
779779+ | Error _ -> ()
780780+ | Ok stanzas ->
781781+ List.iter
782782+ (fun s ->
783783+ match Sexp.Codec.decode_value Package.Library.codec s with
784784+ | Error _ -> ()
785785+ | Ok (lib : Package.Library.t) ->
786786+ if lib.implements <> None then
787787+ Hashtbl.replace t.virtual_impls lib.name ();
788788+ merge_modules t lib.name lib.modules)
789789+ stanzas);
790790+ t
791791+792792+ let add_cmi_modules t ~pkg ~modules =
793793+ merge_modules t pkg modules;
794794+ t
795795+796796+ let modules t lib =
797797+ match Hashtbl.find_opt t.modules lib with
798798+ | None -> []
799799+ | Some s -> String_set.elements s
800800+801801+ let is_virtual_implementation t lib = Hashtbl.mem t.virtual_impls lib
802802+end
+343
lib/dune.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Type-safe codecs for the dune-format file family.
77+88+ Built on {!Sexp.Codec}; one submodule per file kind plus shared helpers for
99+ variable expansion, dune-style pretty printing, field accessors, and
1010+ stanza-kind tagging.
1111+1212+ {2 File kinds}
1313+1414+ - {!module:File} - [dune] build files (libraries, executables, tests)
1515+ - {!module:Project} - [dune-project] files with project metadata
1616+ - {!module:Workspace} - [dune-workspace] files for multi-context builds
1717+ - {!module:Package} - installed [dune-package] metadata files
1818+1919+ {2 Variable expansion}
2020+2121+ Dune supports variable references like [%{name}] / [%{name:default}].
2222+ {!module:Var} parses and expands them. *)
2323+2424+(** {1 Variable Expansion} *)
2525+2626+module Var : sig
2727+ type t
2828+ (** Dune variable reference like [%{name}] or [%{name:default}]. *)
2929+3030+ val v : ?default:string -> string -> t
3131+ (** [v ?default name] creates a variable reference. *)
3232+3333+ val name : t -> string
3434+ (** [name v] is the variable name. *)
3535+3636+ val default : t -> string option
3737+ (** [default v] is the optional default value. *)
3838+3939+ val pp : Format.formatter -> t -> unit
4040+ (** [pp fmt v] prints the variable in Dune syntax. *)
4141+4242+ val to_string : t -> string
4343+ (** [to_string v] converts to Dune variable syntax. *)
4444+end
4545+4646+(** Text segments that may contain variable references. *)
4747+type segment = Text of string | Variable of Var.t
4848+4949+type text = segment list
5050+(** Text with embedded variable references. *)
5151+5252+val text_of_string : string -> text
5353+(** [text_of_string s] parses a string into text segments. *)
5454+5555+val text_to_string : text -> string
5656+(** [text_to_string t] converts text back to a string. *)
5757+5858+val pp_text : Format.formatter -> text -> unit
5959+(** [pp_text fmt t] prints text with variables. *)
6060+6161+val expand_text : env:(string -> string option) -> text -> string
6262+(** [expand_text ~env t] expands variables using [env] lookup. *)
6363+6464+val expand_atom : env:(string -> string option) -> string -> string
6565+(** [expand_atom ~env s] expands variables in a string. *)
6666+6767+val expand : env:(string -> string option) -> Sexp.t -> Sexp.t
6868+(** [expand ~env sexp] recursively expands all variables in atoms. *)
6969+7070+val has_variables : string -> bool
7171+(** [has_variables s] is [true] if [s] contains variable references. *)
7272+7373+val variables : Sexp.t -> string list
7474+(** [variables sexp] extracts all variable names from an S-expression. *)
7575+7676+(** {1 Dune-style Pretty Printing} *)
7777+7878+val pp_dune : Format.formatter -> Sexp.t -> unit
7979+(** [pp_dune fmt sexp] prints an S-expression in Dune style. *)
8080+8181+val to_string_dune : Sexp.t -> string
8282+(** [to_string_dune sexp] converts to a Dune-formatted string. *)
8383+8484+val pp_dune_file : Format.formatter -> Sexp.t list -> unit
8585+(** [pp_dune_file fmt sexps] prints multiple stanzas with blank lines. *)
8686+8787+val to_string_dune_file : Sexp.t list -> string
8888+(** [to_string_dune_file sexps] converts stanzas to a dune file string. *)
8989+9090+(** {1 Field Accessors} *)
9191+9292+val field : string -> Sexp.t -> Sexp.t option
9393+(** [field name sexp] gets a field value from a stanza. *)
9494+9595+val fields : string -> Sexp.t -> Sexp.t list
9696+(** [fields name sexp] gets all values for a repeated field. *)
9797+9898+val field_atom : string -> Sexp.t -> string option
9999+(** [field_atom name sexp] gets a field's atom value. *)
100100+101101+val field_list : string -> Sexp.t -> Sexp.t list option
102102+(** [field_list name sexp] gets a field's list value. *)
103103+104104+val field_atoms : string -> Sexp.t -> string list option
105105+(** [field_atoms name sexp] gets atoms from a list field. *)
106106+107107+val set_field : string -> Sexp.t -> Sexp.t -> Sexp.t
108108+(** [set_field name value sexp] sets or adds a field. *)
109109+110110+val remove_field : string -> Sexp.t -> Sexp.t
111111+(** [remove_field name sexp] removes a field from a stanza. *)
112112+113113+(** {1 Stanza Types} *)
114114+115115+type stanza_kind =
116116+ | Library
117117+ | Executable
118118+ | Executables
119119+ | Test
120120+ | Tests
121121+ | Rule
122122+ | Install
123123+ | Alias
124124+ | Env
125125+ | Include
126126+ | Other of string
127127+128128+val stanza_kind_of_string : string -> stanza_kind
129129+(** [stanza_kind_of_string s] converts a string to a stanza kind. *)
130130+131131+val stanza_kind_to_string : stanza_kind -> string
132132+(** [stanza_kind_to_string k] converts a stanza kind to string. *)
133133+134134+val stanza_kind : Sexp.t -> stanza_kind option
135135+(** [stanza_kind sexp] extracts the kind from a stanza. *)
136136+137137+(** {1 Stanza Builders} *)
138138+139139+val library :
140140+ ?public_name:string ->
141141+ ?libraries:string list ->
142142+ ?preprocess:Sexp.t ->
143143+ string ->
144144+ Sexp.t
145145+(** [library name] builds a [(library ...)] stanza. *)
146146+147147+val executable :
148148+ ?public_name:string ->
149149+ ?libraries:string list ->
150150+ ?preprocess:Sexp.t ->
151151+ string ->
152152+ Sexp.t
153153+(** [executable name] builds an [(executable ...)] stanza. *)
154154+155155+val test : ?libraries:string list -> ?modules:string list -> string -> Sexp.t
156156+(** [test name] builds a [(test ...)] stanza. *)
157157+158158+(** {1 dune-project Files} *)
159159+160160+module Project : sig
161161+ type t = {
162162+ lang : string * string;
163163+ name : string option;
164164+ version : string option;
165165+ generate_opam_files : bool option;
166166+ license : string option;
167167+ authors : string list;
168168+ maintainers : string list;
169169+ source : string option;
170170+ bug_reports : string option;
171171+ homepage : string option;
172172+ documentation : string option;
173173+ packages : Sexp.t list;
174174+ other : Sexp.t list;
175175+ }
176176+ (** Parsed dune-project file. *)
177177+178178+ val empty : t
179179+ (** [empty] is an empty dune-project with default lang. *)
180180+181181+ val make :
182182+ ?dune_version:string ->
183183+ ?name:string ->
184184+ ?version:string ->
185185+ ?generate_opam_files:bool ->
186186+ ?license:string ->
187187+ ?authors:string list ->
188188+ ?maintainers:string list ->
189189+ ?source:string ->
190190+ ?bug_reports:string ->
191191+ ?homepage:string ->
192192+ ?documentation:string ->
193193+ ?packages:Sexp.t list ->
194194+ unit ->
195195+ t
196196+ (** [make ()] creates a dune-project with the given fields. *)
197197+198198+ val parse : Sexp.t list -> t
199199+ (** [parse sexps] parses S-expressions into a dune-project. *)
200200+201201+ val to_sexps : t -> Sexp.t list
202202+ (** [to_sexps t] converts to S-expressions. *)
203203+204204+ val to_string : t -> string
205205+ (** [to_string t] converts to a dune-project file string. *)
206206+end
207207+208208+(** {1 dune-workspace Files} *)
209209+210210+module Workspace : sig
211211+ type context_kind = Default | Opam of { switch : string } | Other of Sexp.t
212212+ type context = { name : string option; kind : context_kind }
213213+214214+ type t = {
215215+ lang : string * string;
216216+ contexts : context list;
217217+ env : Sexp.t option;
218218+ other : Sexp.t list;
219219+ }
220220+ (** Parsed dune-workspace file. *)
221221+222222+ val empty : t
223223+ (** [empty] is an empty dune-workspace. *)
224224+225225+ val parse : Sexp.t list -> t
226226+ (** [parse sexps] parses S-expressions into a dune-workspace. *)
227227+228228+ val to_sexps : t -> Sexp.t list
229229+ (** [to_sexps t] converts to S-expressions. *)
230230+231231+ val to_string : t -> string
232232+ (** [to_string t] converts to a dune-workspace file string. *)
233233+end
234234+235235+(** {1 dune Files} *)
236236+237237+module File : sig
238238+ type t = Sexp.t list
239239+ (** A dune file is a list of stanzas. *)
240240+241241+ val parse : Sexp.t list -> t
242242+ (** [parse sexps] parses S-expressions as a dune file. *)
243243+244244+ val to_sexps : t -> Sexp.t list
245245+ (** [to_sexps t] converts to S-expressions. *)
246246+247247+ val to_string : t -> string
248248+ (** [to_string t] converts to a dune file string. *)
249249+250250+ val find_stanza : stanza_kind -> t -> Sexp.t option
251251+ (** [find_stanza kind t] finds the first stanza of the given kind. *)
252252+253253+ val find_stanzas : stanza_kind -> t -> Sexp.t list
254254+ (** [find_stanzas kind t] finds all stanzas of the given kind. *)
255255+256256+ val library_names : t -> string list
257257+ (** [library_names t] extracts all library names. *)
258258+259259+ val private_library_names : t -> string list
260260+ (** [private_library_names t] extracts the names of [(library (name X))]
261261+ stanzas that have no [(public_name ...)]. These are workspace-private
262262+ libraries: sibling stanzas can [(libraries X)] them, but they ship nowhere
263263+ and must not appear in opam depends. *)
264264+265265+ val executable_names : t -> string list
266266+ (** [executable_names t] extracts all executable names. *)
267267+268268+ val test_names : t -> string list
269269+ (** [test_names t] extracts all test names. *)
270270+end
271271+272272+(** {1 dune-package Files} *)
273273+274274+module Package : sig
275275+ (** Parsed [_opam/lib/<pkg>/dune-package] / [_build/install/.../dune-package]
276276+ metadata. Dune writes one of these per installed package; it lists every
277277+ sub-library along with its exposed module names. *)
278278+279279+ module Library : sig
280280+ type t = {
281281+ name : string;
282282+ (** Public library name, e.g. [helix.jx.jsoo]. May contain dots. *)
283283+ main_module_name : string option;
284284+ (** Top-level wrapper module when the library is wrapped. *)
285285+ modules : string list;
286286+ (** All exposed module names (capitalised, [obj_name] form). Includes
287287+ [main_module_name] when present. *)
288288+ implements : string option;
289289+ (** When present, this library is the concrete implementation of the
290290+ named virtual library. Such libraries are link-time live even if
291291+ none of their modules appear in source code. *)
292292+ }
293293+ (** A single [(library ...)] sub-stanza of a dune-package file. *)
294294+295295+ val codec : t Sexp.codec
296296+ (** Codec for a single [(library ...)] stanza. Decoding any other stanza
297297+ shape returns an error. *)
298298+ end
299299+end
300300+301301+(** {1 Library Index} *)
302302+303303+module Lib_index : sig
304304+ (** Aggregate index keyed by library name across many installed packages.
305305+306306+ Build it incrementally: feed each package directory in via either
307307+ {!add_dune_package} (precise — uses the [dune-package] metadata) or
308308+ {!add_cmi_modules} (fallback — for packages installed without
309309+ [dune-package], the convention is to enumerate [*.cmi] basenames in the
310310+ install directory).
311311+312312+ I/O is the caller's job. Pass {!add_dune_package} the {e contents} of a
313313+ [dune-package] file you already read, and {!add_cmi_modules} the list of
314314+ [*.cmi] basenames you already enumerated. The index then knows which
315315+ top-level modules each library exposes and which libraries are concrete
316316+ implementations of virtual libraries. *)
317317+318318+ type t
319319+320320+ val empty : unit -> t
321321+ (** [empty ()] is a fresh, mutable index with no libraries. *)
322322+323323+ val add_dune_package : t -> string -> t
324324+ (** [add_dune_package t content] parses [content] as a [dune-package] file and
325325+ folds every library it declares into [t]. Updates the virtual-impl set for
326326+ any [(implements X)] entries and unions modules under each library's
327327+ public name. *)
328328+329329+ val add_cmi_modules : t -> pkg:string -> modules:string list -> t
330330+ (** [add_cmi_modules t ~pkg ~modules] records [pkg]'s exposed top-level
331331+ modules, used as a fallback when no [dune-package] file is present. The
332332+ caller has already filtered out wrapped-private modules (basenames
333333+ containing [__]) and capitalised the remainder. *)
334334+335335+ val modules : t -> string -> string list
336336+ (** [modules t lib] is the list of top-level module names exposed by [lib], or
337337+ [[]] if [lib] is unknown. *)
338338+339339+ val is_virtual_implementation : t -> string -> bool
340340+ (** [is_virtual_implementation t lib] is [true] iff [lib] has an
341341+ [(implements X)] entry in its [dune-package]. Such libraries are link-time
342342+ live even when none of their modules appear in source. *)
343343+end
+41
nox-dune.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "Type-safe codecs for dune build files"
44+description: """
55+Codec combinators for the dune-format file family: dune build files,
66+dune-project, dune-workspace, and the dune-package metadata files dune
77+emits for installed libraries. Built on nox-sexp's Codec API. Mirrors
88+the ocaml-opam / nox-opam split: nox-sexp owns the generic sexp value
99+AST and codec primitives, nox-dune owns dune-format-specific decoders
1010+and stanza accessors."""
1111+maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
1212+authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
1313+license: "ISC"
1414+tags: ["org:blacksun" "codec.text"]
1515+homepage: "https://tangled.org/gazagnaire.org/ocaml-dune"
1616+bug-reports: "https://tangled.org/gazagnaire.org/ocaml-dune/issues"
1717+depends: [
1818+ "dune" {>= "3.21"}
1919+ "ocaml" {>= "4.14.0"}
2020+ "fmt" {>= "0.9.0"}
2121+ "nox-sexp"
2222+ "mdx" {with-test}
2323+ "alcotest" {with-test}
2424+ "odoc" {with-doc}
2525+]
2626+build: [
2727+ ["dune" "subst"] {dev}
2828+ [
2929+ "dune"
3030+ "build"
3131+ "-p"
3232+ name
3333+ "-j"
3434+ jobs
3535+ "@install"
3636+ "@runtest" {with-test}
3737+ "@doc" {with-doc}
3838+ ]
3939+]
4040+dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-dune"
4141+x-maintenance-intent: ["(latest)"]