···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+open Bytesrw
77+88+(** INI parser and encoder using bytesrw.
99+1010+ Implements Python configparser semantics including:
1111+ - Multiline values via indentation
1212+ - Basic interpolation: %(name)s
1313+ - Extended interpolation: ${section:name}
1414+ - DEFAULT section inheritance
1515+ - Case-insensitive option lookup *)
1616+1717+(* ---- Configuration ---- *)
1818+1919+type interpolation =
2020+ | No_interpolation
2121+ | Basic_interpolation
2222+ | Extended_interpolation
2323+2424+type config = {
2525+ delimiters : string list;
2626+ comment_prefixes : string list;
2727+ inline_comment_prefixes : string list;
2828+ default_section : string;
2929+ interpolation : interpolation;
3030+ allow_no_value : bool;
3131+ strict : bool;
3232+ empty_lines_in_values : bool;
3333+}
3434+3535+let default_config = {
3636+ delimiters = ["="; ":"];
3737+ comment_prefixes = ["#"; ";"];
3838+ inline_comment_prefixes = [];
3939+ default_section = "DEFAULT";
4040+ interpolation = Basic_interpolation;
4141+ allow_no_value = false;
4242+ strict = true;
4343+ empty_lines_in_values = true;
4444+}
4545+4646+let raw_config = { default_config with interpolation = No_interpolation }
4747+4848+(* ---- Reading from bytesrw ---- *)
4949+5050+let read_all_to_string reader =
5151+ let buf = Buffer.create 4096 in
5252+ let rec loop () =
5353+ let slice = Bytes.Reader.read reader in
5454+ if Bytes.Slice.length slice = 0 then
5555+ Buffer.contents buf
5656+ else begin
5757+ Buffer.add_subbytes buf
5858+ (Bytes.Slice.bytes slice)
5959+ (Bytes.Slice.first slice)
6060+ (Bytes.Slice.length slice);
6161+ loop ()
6262+ end
6363+ in
6464+ loop ()
6565+6666+(* ---- Parsing State ---- *)
6767+6868+type parse_state = {
6969+ mutable file : string;
7070+ mutable line_num : int;
7171+ mutable byte_pos : int;
7272+ mutable line_start_byte : int;
7373+ config : config;
7474+ (* Accumulated data *)
7575+ mutable defaults : (string Init.node * Init.Repr.ini_value) list;
7676+ mutable sections : Init.Repr.ini_section list;
7777+ (* Current parse state *)
7878+ mutable cur_section : string option;
7979+ mutable cur_option : (string * Init.Meta.t) option;
8080+ mutable cur_value : string list;
8181+ mutable cur_indent : int;
8282+ mutable cur_value_meta : Init.Meta.t;
8383+ mutable pending_ws : string;
8484+}
8585+8686+let make_state config file = {
8787+ file;
8888+ line_num = 1;
8989+ byte_pos = 0;
9090+ line_start_byte = 0;
9191+ config;
9292+ defaults = [];
9393+ sections = [];
9494+ cur_section = None;
9595+ cur_option = None;
9696+ cur_value = [];
9797+ cur_indent = 0;
9898+ cur_value_meta = Init.Meta.none;
9999+ pending_ws = "";
100100+}
101101+102102+let current_textloc state first_byte last_byte first_line =
103103+ Init.Textloc.make
104104+ ~file:state.file
105105+ ~first_byte ~last_byte
106106+ ~first_line
107107+ ~last_line:(state.line_num, state.line_start_byte)
108108+109109+let current_meta state first_byte first_line =
110110+ let textloc = current_textloc state first_byte state.byte_pos first_line in
111111+ Init.Meta.make textloc
112112+113113+(* ---- String Utilities ---- *)
114114+115115+let string_starts_with ~prefix s =
116116+ let plen = String.length prefix in
117117+ let slen = String.length s in
118118+ slen >= plen && String.sub s 0 plen = prefix
119119+120120+let lstrip s =
121121+ let len = String.length s in
122122+ let rec find_start i =
123123+ if i >= len then len
124124+ else match s.[i] with
125125+ | ' ' | '\t' -> find_start (i + 1)
126126+ | _ -> i
127127+ in
128128+ let start = find_start 0 in
129129+ if start = 0 then s
130130+ else String.sub s start (len - start)
131131+132132+let rstrip s =
133133+ let rec find_end i =
134134+ if i < 0 then -1
135135+ else match s.[i] with
136136+ | ' ' | '\t' | '\r' | '\n' -> find_end (i - 1)
137137+ | _ -> i
138138+ in
139139+ let end_pos = find_end (String.length s - 1) in
140140+ if end_pos = String.length s - 1 then s
141141+ else String.sub s 0 (end_pos + 1)
142142+143143+let strip s = lstrip (rstrip s)
144144+145145+let count_indent s =
146146+ let len = String.length s in
147147+ let rec count i =
148148+ if i >= len then i
149149+ else match s.[i] with
150150+ | ' ' | '\t' -> count (i + 1)
151151+ | _ -> i
152152+ in
153153+ count 0
154154+155155+(* ---- Comment and Delimiter Handling ---- *)
156156+157157+let is_comment_line config line =
158158+ let trimmed = lstrip line in
159159+ List.exists (fun prefix -> string_starts_with ~prefix trimmed) config.comment_prefixes
160160+161161+let is_empty_line line =
162162+ String.length (strip line) = 0
163163+164164+let strip_inline_comment config value =
165165+ if config.inline_comment_prefixes = [] then value
166166+ else
167167+ (* Find inline comment with preceding whitespace *)
168168+ let len = String.length value in
169169+ let rec find_comment i =
170170+ if i >= len then value
171171+ else if value.[i] = ' ' || value.[i] = '\t' then begin
172172+ let rest = String.sub value i (len - i) in
173173+ let trimmed = lstrip rest in
174174+ if List.exists (fun p -> string_starts_with ~prefix:p trimmed) config.inline_comment_prefixes then
175175+ rstrip (String.sub value 0 i)
176176+ else
177177+ find_comment (i + 1)
178178+ end
179179+ else find_comment (i + 1)
180180+ in
181181+ find_comment 0
182182+183183+let find_delimiter config line =
184184+ let trimmed = lstrip line in
185185+ let len = String.length trimmed in
186186+ let rec try_delimiters delims =
187187+ match delims with
188188+ | [] -> None
189189+ | delim :: rest ->
190190+ let dlen = String.length delim in
191191+ let rec find_at i =
192192+ if i + dlen > len then try_delimiters rest
193193+ else if String.sub trimmed i dlen = delim then Some (delim, i)
194194+ else find_at (i + 1)
195195+ in
196196+ find_at 0
197197+ in
198198+ try_delimiters config.delimiters
199199+200200+(* ---- Section Header Parsing ---- *)
201201+202202+let parse_section_header line =
203203+ let trimmed = strip line in
204204+ let len = String.length trimmed in
205205+ if len >= 2 && trimmed.[0] = '[' && trimmed.[len - 1] = ']' then
206206+ Some (String.sub trimmed 1 (len - 2))
207207+ else
208208+ None
209209+210210+(* ---- Interpolation ---- *)
211211+212212+let rec basic_interpolate ~section ~defaults ~sections value max_depth =
213213+ if max_depth <= 0 then
214214+ Error (Init.Error.make (Init.Error.Interpolation {
215215+ option = ""; reason = "recursion depth exceeded" }))
216216+ else
217217+ let buf = Buffer.create (String.length value) in
218218+ let len = String.length value in
219219+ let rec scan i =
220220+ if i >= len then Ok (Buffer.contents buf)
221221+ else if i + 1 < len && value.[i] = '%' && value.[i+1] = '%' then begin
222222+ Buffer.add_char buf '%';
223223+ scan (i + 2)
224224+ end
225225+ else if value.[i] = '%' && i + 1 < len && value.[i+1] = '(' then begin
226226+ (* Find closing )s *)
227227+ let rec find_close j =
228228+ if j + 1 >= len then None
229229+ else if value.[j] = ')' && value.[j+1] = 's' then Some j
230230+ else find_close (j + 1)
231231+ in
232232+ match find_close (i + 2) with
233233+ | None ->
234234+ Buffer.add_char buf value.[i];
235235+ scan (i + 1)
236236+ | Some close_pos ->
237237+ let name = String.lowercase_ascii (String.sub value (i + 2) (close_pos - i - 2)) in
238238+ (* Look up value: current section first, then defaults *)
239239+ let lookup_result =
240240+ let find_in_opts opts =
241241+ List.find_opt (fun ((n, _), _) ->
242242+ String.lowercase_ascii n = name) opts
243243+ in
244244+ match section with
245245+ | None -> find_in_opts defaults
246246+ | Some sec ->
247247+ let sec_opts = List.find_opt (fun s ->
248248+ String.lowercase_ascii (fst s.Init.Repr.name) =
249249+ String.lowercase_ascii sec
250250+ ) sections in
251251+ match sec_opts with
252252+ | Some s ->
253253+ (match find_in_opts s.Init.Repr.options with
254254+ | Some x -> Some x
255255+ | None -> find_in_opts defaults)
256256+ | None -> find_in_opts defaults
257257+ in
258258+ match lookup_result with
259259+ | None ->
260260+ Error (Init.Error.make (Init.Error.Interpolation {
261261+ option = name; reason = "option not found" }))
262262+ | Some (_, iv) ->
263263+ (* Recursively interpolate the referenced value *)
264264+ match basic_interpolate ~section ~defaults ~sections iv.Init.Repr.raw (max_depth - 1) with
265265+ | Error e -> Error e
266266+ | Ok interpolated ->
267267+ Buffer.add_string buf interpolated;
268268+ scan (close_pos + 2)
269269+ end
270270+ else begin
271271+ Buffer.add_char buf value.[i];
272272+ scan (i + 1)
273273+ end
274274+ in
275275+ scan 0
276276+277277+let rec extended_interpolate ~section ~defaults ~sections value max_depth =
278278+ if max_depth <= 0 then
279279+ Error (Init.Error.make (Init.Error.Interpolation {
280280+ option = ""; reason = "recursion depth exceeded" }))
281281+ else
282282+ let buf = Buffer.create (String.length value) in
283283+ let len = String.length value in
284284+ let rec scan i =
285285+ if i >= len then Ok (Buffer.contents buf)
286286+ else if i + 1 < len && value.[i] = '$' && value.[i+1] = '$' then begin
287287+ Buffer.add_char buf '$';
288288+ scan (i + 2)
289289+ end
290290+ else if value.[i] = '$' && i + 1 < len && value.[i+1] = '{' then begin
291291+ (* Find closing } *)
292292+ let rec find_close j =
293293+ if j >= len then None
294294+ else if value.[j] = '}' then Some j
295295+ else find_close (j + 1)
296296+ in
297297+ match find_close (i + 2) with
298298+ | None ->
299299+ Buffer.add_char buf value.[i];
300300+ scan (i + 1)
301301+ | Some close_pos ->
302302+ let ref_str = String.sub value (i + 2) (close_pos - i - 2) in
303303+ (* Parse section:name or just name *)
304304+ let (ref_section, name) =
305305+ match String.index_opt ref_str ':' with
306306+ | None -> (section, String.lowercase_ascii ref_str)
307307+ | Some colon_pos ->
308308+ let sec = String.sub ref_str 0 colon_pos in
309309+ let n = String.sub ref_str (colon_pos + 1) (String.length ref_str - colon_pos - 1) in
310310+ (Some sec, String.lowercase_ascii n)
311311+ in
312312+ (* Look up value *)
313313+ let lookup_result =
314314+ let find_in_opts opts =
315315+ List.find_opt (fun ((n, _), _) ->
316316+ String.lowercase_ascii n = name) opts
317317+ in
318318+ match ref_section with
319319+ | None -> find_in_opts defaults
320320+ | Some sec ->
321321+ let lc_sec = String.lowercase_ascii sec in
322322+ if lc_sec = String.lowercase_ascii "default" then
323323+ find_in_opts defaults
324324+ else
325325+ let sec_opts = List.find_opt (fun s ->
326326+ String.lowercase_ascii (fst s.Init.Repr.name) = lc_sec
327327+ ) sections in
328328+ match sec_opts with
329329+ | Some s ->
330330+ (match find_in_opts s.Init.Repr.options with
331331+ | Some x -> Some x
332332+ | None -> find_in_opts defaults)
333333+ | None -> find_in_opts defaults
334334+ in
335335+ match lookup_result with
336336+ | None ->
337337+ Error (Init.Error.make (Init.Error.Interpolation {
338338+ option = name; reason = "option not found" }))
339339+ | Some (_, iv) ->
340340+ (* Recursively interpolate *)
341341+ match extended_interpolate ~section:ref_section ~defaults ~sections iv.Init.Repr.raw (max_depth - 1) with
342342+ | Error e -> Error e
343343+ | Ok interpolated ->
344344+ Buffer.add_string buf interpolated;
345345+ scan (close_pos + 1)
346346+ end
347347+ else begin
348348+ Buffer.add_char buf value.[i];
349349+ scan (i + 1)
350350+ end
351351+ in
352352+ scan 0
353353+354354+let interpolate config ~section ~defaults ~sections value =
355355+ match config.interpolation with
356356+ | No_interpolation -> Ok value
357357+ | Basic_interpolation -> basic_interpolate ~section ~defaults ~sections value 10
358358+ | Extended_interpolation -> extended_interpolate ~section ~defaults ~sections value 10
359359+360360+(* ---- Option Finalization ---- *)
361361+362362+let finalize_current_option state =
363363+ match state.cur_option with
364364+ | None -> ()
365365+ | Some (name, name_meta) ->
366366+ let raw_value = String.concat "\n" (List.rev state.cur_value) in
367367+ let value = strip raw_value in
368368+ let iv = {
369369+ Init.Repr.raw = value;
370370+ interpolated = value; (* Will be interpolated later *)
371371+ meta = state.cur_value_meta;
372372+ } in
373373+ let opt = ((name, name_meta), iv) in
374374+ (match state.cur_section with
375375+ | None ->
376376+ (* DEFAULT section *)
377377+ state.defaults <- opt :: state.defaults
378378+ | Some sec ->
379379+ (* Add to current section *)
380380+ match state.sections with
381381+ | [] ->
382382+ let new_sec = {
383383+ Init.Repr.name = (sec, Init.Meta.none);
384384+ options = [opt];
385385+ meta = Init.Meta.none;
386386+ } in
387387+ state.sections <- [new_sec]
388388+ | sec_data :: rest when fst sec_data.name = sec ->
389389+ state.sections <- { sec_data with options = opt :: sec_data.options } :: rest
390390+ | _ ->
391391+ let new_sec = {
392392+ Init.Repr.name = (sec, Init.Meta.none);
393393+ options = [opt];
394394+ meta = Init.Meta.none;
395395+ } in
396396+ state.sections <- new_sec :: state.sections);
397397+ state.cur_option <- None;
398398+ state.cur_value <- [];
399399+ state.cur_indent <- 0
400400+401401+(* ---- Line Processing ---- *)
402402+403403+let process_line state line =
404404+ let line_start = state.byte_pos in
405405+ let line_start_line = (state.line_num, state.line_start_byte) in
406406+ state.byte_pos <- state.byte_pos + String.length line + 1; (* +1 for newline *)
407407+ state.line_num <- state.line_num + 1;
408408+ state.line_start_byte <- state.byte_pos;
409409+410410+ (* Check for empty line *)
411411+ if is_empty_line line then begin
412412+ if state.cur_option <> None && state.config.empty_lines_in_values then
413413+ state.cur_value <- "" :: state.cur_value
414414+ else begin
415415+ finalize_current_option state;
416416+ state.pending_ws <- state.pending_ws ^ line ^ "\n"
417417+ end;
418418+ Ok ()
419419+ end
420420+ (* Check for comment *)
421421+ else if is_comment_line state.config line then begin
422422+ if state.cur_option <> None then
423423+ (* Comment within multiline - finalize. *)
424424+ finalize_current_option state;
425425+ state.pending_ws <- state.pending_ws ^ line ^ "\n";
426426+ Ok ()
427427+ end
428428+ (* Check for section header *)
429429+ else match parse_section_header line with
430430+ | Some sec_name ->
431431+ finalize_current_option state;
432432+ let lc_sec = sec_name in (* Keep original case for section names *)
433433+ if String.lowercase_ascii sec_name = String.lowercase_ascii state.config.default_section then begin
434434+ state.cur_section <- None;
435435+ Ok ()
436436+ end
437437+ else if state.config.strict then begin
438438+ (* Check for duplicate section *)
439439+ let exists = List.exists (fun s ->
440440+ String.lowercase_ascii (fst s.Init.Repr.name) = String.lowercase_ascii sec_name
441441+ ) state.sections in
442442+ if exists then
443443+ Error (Init.Error.make
444444+ ~meta:(current_meta state line_start line_start_line)
445445+ (Init.Error.Duplicate_section sec_name))
446446+ else begin
447447+ let sec_meta = current_meta state line_start line_start_line in
448448+ let sec_meta = Init.Meta.with_ws_before sec_meta state.pending_ws in
449449+ state.pending_ws <- "";
450450+ let new_sec = {
451451+ Init.Repr.name = (lc_sec, sec_meta);
452452+ options = [];
453453+ meta = sec_meta;
454454+ } in
455455+ state.sections <- new_sec :: state.sections;
456456+ state.cur_section <- Some lc_sec;
457457+ Ok ()
458458+ end
459459+ end
460460+ else begin
461461+ let sec_meta = current_meta state line_start line_start_line in
462462+ let sec_meta = Init.Meta.with_ws_before sec_meta state.pending_ws in
463463+ state.pending_ws <- "";
464464+ let new_sec = {
465465+ Init.Repr.name = (lc_sec, sec_meta);
466466+ options = [];
467467+ meta = sec_meta;
468468+ } in
469469+ state.sections <- new_sec :: state.sections;
470470+ state.cur_section <- Some lc_sec;
471471+ Ok ()
472472+ end
473473+ | None ->
474474+ (* Check for continuation of multiline value *)
475475+ let indent = count_indent line in
476476+ if state.cur_option <> None && indent > state.cur_indent then begin
477477+ (* Continuation line *)
478478+ let value_part = strip line in
479479+ state.cur_value <- value_part :: state.cur_value;
480480+ Ok ()
481481+ end
482482+ else begin
483483+ (* New option or continuation *)
484484+ finalize_current_option state;
485485+ (* Try to parse as option = value *)
486486+ match find_delimiter state.config line with
487487+ | Some (delim, pos) ->
488488+ let stripped = lstrip line in
489489+ let key = String.sub stripped 0 pos in
490490+ let key = String.lowercase_ascii (rstrip key) in (* Case-fold option names *)
491491+ let value_start = pos + String.length delim in
492492+ let rest = String.sub stripped value_start (String.length stripped - value_start) in
493493+ let value = strip_inline_comment state.config (lstrip rest) in
494494+ if state.cur_section = None && state.sections = [] && state.defaults = [] then
495495+ (* No section header yet - this is DEFAULT section *)
496496+ ();
497497+ let opt_meta = current_meta state line_start line_start_line in
498498+ let opt_meta = Init.Meta.with_ws_before opt_meta state.pending_ws in
499499+ state.pending_ws <- "";
500500+ state.cur_option <- Some (key, opt_meta);
501501+ state.cur_value <- [value];
502502+ state.cur_indent <- count_indent line;
503503+ state.cur_value_meta <- opt_meta;
504504+ Ok ()
505505+ | None ->
506506+ if state.config.allow_no_value then begin
507507+ (* Valueless option *)
508508+ let key = String.lowercase_ascii (strip line) in
509509+ let opt_meta = current_meta state line_start line_start_line in
510510+ let opt_meta = Init.Meta.with_ws_before opt_meta state.pending_ws in
511511+ state.pending_ws <- "";
512512+ state.cur_option <- Some (key, opt_meta);
513513+ state.cur_value <- [];
514514+ state.cur_indent <- count_indent line;
515515+ state.cur_value_meta <- opt_meta;
516516+ Ok ()
517517+ end
518518+ else
519519+ Error (Init.Error.make
520520+ ~meta:(current_meta state line_start line_start_line)
521521+ (Init.Error.Parse ("no delimiter found in line: " ^ line)))
522522+ end
523523+524524+(* ---- Interpolation Pass ---- *)
525525+526526+let perform_interpolation state =
527527+ let interpolate_value ~section iv =
528528+ match interpolate state.config ~section ~defaults:state.defaults ~sections:state.sections iv.Init.Repr.raw with
529529+ | Ok interpolated -> Ok { iv with Init.Repr.interpolated = interpolated }
530530+ | Error e -> Error e
531531+ in
532532+ let interpolate_opts ~section opts =
533533+ let rec loop acc = function
534534+ | [] -> Ok (List.rev acc)
535535+ | ((name, meta), iv) :: rest ->
536536+ match interpolate_value ~section iv with
537537+ | Ok iv' -> loop (((name, meta), iv') :: acc) rest
538538+ | Error e -> Error e
539539+ in
540540+ loop [] opts
541541+ in
542542+ (* Interpolate defaults *)
543543+ match interpolate_opts ~section:None state.defaults with
544544+ | Error e -> Error e
545545+ | Ok defaults' ->
546546+ state.defaults <- defaults';
547547+ (* Interpolate sections *)
548548+ let rec loop_sections acc = function
549549+ | [] -> Ok (List.rev acc)
550550+ | sec :: rest ->
551551+ match interpolate_opts ~section:(Some (fst sec.Init.Repr.name)) sec.options with
552552+ | Ok opts' -> loop_sections ({ sec with options = opts' } :: acc) rest
553553+ | Error e -> Error e
554554+ in
555555+ match loop_sections [] state.sections with
556556+ | Error e -> Error e
557557+ | Ok sections' ->
558558+ state.sections <- sections';
559559+ Ok ()
560560+561561+(* ---- Line splitting ---- *)
562562+563563+let split_lines s =
564564+ let len = String.length s in
565565+ if len = 0 then []
566566+ else
567567+ let rec split acc start i =
568568+ if i >= len then
569569+ let last = String.sub s start (len - start) in
570570+ List.rev (if String.length last > 0 then last :: acc else acc)
571571+ else match s.[i] with
572572+ | '\n' ->
573573+ let line = String.sub s start (i - start) in
574574+ split (line :: acc) (i + 1) (i + 1)
575575+ | '\r' ->
576576+ let line = String.sub s start (i - start) in
577577+ let next = if i + 1 < len && s.[i + 1] = '\n' then i + 2 else i + 1 in
578578+ split (line :: acc) next next
579579+ | _ -> split acc start (i + 1)
580580+ in
581581+ split [] 0 0
582582+583583+(* ---- Main Parse Functions ---- *)
584584+585585+let parse_string_internal ?(config=default_config) ?(locs=false) ?(layout=false) ?(file=Init.Textloc.file_none) s =
586586+ let _ = locs in (* TODO: Use locs to control location tracking *)
587587+ let _ = layout in (* TODO: Use layout to control whitespace preservation *)
588588+ let state = make_state config file in
589589+ let lines = split_lines s in
590590+ let rec process = function
591591+ | [] ->
592592+ finalize_current_option state;
593593+ Ok ()
594594+ | line :: rest ->
595595+ match process_line state line with
596596+ | Ok () -> process rest
597597+ | Error e -> Error e
598598+ in
599599+ match process lines with
600600+ | Error e -> Error e
601601+ | Ok () ->
602602+ (* Perform interpolation *)
603603+ match perform_interpolation state with
604604+ | Error e -> Error e
605605+ | Ok () ->
606606+ let doc = {
607607+ Init.Repr.defaults = List.rev state.defaults;
608608+ sections = List.rev_map (fun (sec : Init.Repr.ini_section) ->
609609+ { sec with options = List.rev sec.options }
610610+ ) state.sections;
611611+ meta = Init.Meta.none;
612612+ } in
613613+ Ok doc
614614+615615+let parse_reader ?(config=default_config) ?(locs=false) ?(layout=false) ?(file=Init.Textloc.file_none) reader =
616616+ let s = read_all_to_string reader in
617617+ parse_string_internal ~config ~locs ~layout ~file s
618618+619619+let parse_string ?(config=default_config) ?(locs=false) ?(layout=false) ?(file=Init.Textloc.file_none) s =
620620+ parse_string_internal ~config ~locs ~layout ~file s
621621+622622+(* ---- Decoding ---- *)
623623+624624+let decode' ?(config=default_config) ?(locs=false) ?(layout=false) ?(file=Init.Textloc.file_none) codec reader =
625625+ match parse_reader ~config ~locs ~layout ~file reader with
626626+ | Error e -> Error e
627627+ | Ok doc ->
628628+ match Init.document_state codec with
629629+ | Some doc_state -> doc_state.decode doc
630630+ | None ->
631631+ (* Maybe it's a section codec - try to decode from first/only section *)
632632+ match Init.section_state codec with
633633+ | Some sec_state ->
634634+ (match doc.sections with
635635+ | [sec] -> sec_state.decode sec
636636+ | [] -> Error (Init.Error.make (Init.Error.Codec "no sections in document"))
637637+ | _ -> Error (Init.Error.make (Init.Error.Codec "multiple sections; expected single section codec")))
638638+ | None ->
639639+ Error (Init.Error.make (Init.Error.Codec "codec is neither document nor section type"))
640640+641641+let decode ?config ?locs ?layout ?file codec reader =
642642+ match decode' ?config ?locs ?layout ?file codec reader with
643643+ | Ok v -> Ok v
644644+ | Error e -> Error (Init.Error.to_string e)
645645+646646+let decode_string' ?(config=default_config) ?(locs=false) ?(layout=false) ?(file=Init.Textloc.file_none) codec s =
647647+ match parse_string ~config ~locs ~layout ~file s with
648648+ | Error e -> Error e
649649+ | Ok doc ->
650650+ match Init.document_state codec with
651651+ | Some doc_state -> doc_state.decode doc
652652+ | None ->
653653+ match Init.section_state codec with
654654+ | Some sec_state ->
655655+ (match doc.sections with
656656+ | [sec] -> sec_state.decode sec
657657+ | [] -> Error (Init.Error.make (Init.Error.Codec "no sections in document"))
658658+ | _ -> Error (Init.Error.make (Init.Error.Codec "multiple sections; expected single section codec")))
659659+ | None ->
660660+ Error (Init.Error.make (Init.Error.Codec "codec is neither document nor section type"))
661661+662662+let decode_string ?config ?locs ?layout ?file codec s =
663663+ match decode_string' ?config ?locs ?layout ?file codec s with
664664+ | Ok v -> Ok v
665665+ | Error e -> Error (Init.Error.to_string e)
666666+667667+(* ---- Encoding ---- *)
668668+669669+let encode_to_buffer buf codec value =
670670+ match Init.document_state codec with
671671+ | Some doc_state ->
672672+ let doc = doc_state.encode value in
673673+ (* Encode defaults *)
674674+ if doc.defaults <> [] then begin
675675+ Buffer.add_string buf "[DEFAULT]\n";
676676+ List.iter (fun ((name, _), iv) ->
677677+ Buffer.add_string buf name;
678678+ Buffer.add_string buf " = ";
679679+ Buffer.add_string buf iv.Init.Repr.raw;
680680+ Buffer.add_char buf '\n'
681681+ ) doc.defaults;
682682+ Buffer.add_char buf '\n'
683683+ end;
684684+ (* Encode sections *)
685685+ List.iter (fun (sec : Init.Repr.ini_section) ->
686686+ Buffer.add_char buf '[';
687687+ Buffer.add_string buf (fst sec.name);
688688+ Buffer.add_string buf "]\n";
689689+ List.iter (fun ((name, _), iv) ->
690690+ Buffer.add_string buf name;
691691+ Buffer.add_string buf " = ";
692692+ Buffer.add_string buf iv.Init.Repr.raw;
693693+ Buffer.add_char buf '\n'
694694+ ) sec.options;
695695+ Buffer.add_char buf '\n'
696696+ ) doc.sections;
697697+ Ok ()
698698+ | None ->
699699+ match Init.section_state codec with
700700+ | Some sec_state ->
701701+ let sec = sec_state.encode value in
702702+ Buffer.add_char buf '[';
703703+ Buffer.add_string buf (fst sec.name);
704704+ Buffer.add_string buf "]\n";
705705+ List.iter (fun ((name, _), iv) ->
706706+ Buffer.add_string buf name;
707707+ Buffer.add_string buf " = ";
708708+ Buffer.add_string buf iv.Init.Repr.raw;
709709+ Buffer.add_char buf '\n'
710710+ ) sec.options;
711711+ Ok ()
712712+ | None ->
713713+ Error (Init.Error.make (Init.Error.Codec "codec is neither document nor section type"))
714714+715715+let encode' ?buf:_ codec value ~eod writer =
716716+ let buffer = Buffer.create 1024 in
717717+ match encode_to_buffer buffer codec value with
718718+ | Error e -> Error e
719719+ | Ok () ->
720720+ let s = Buffer.contents buffer in
721721+ Bytes.Writer.write_string writer s;
722722+ if eod then Bytes.Writer.write_eod writer;
723723+ Ok ()
724724+725725+let encode ?buf codec value ~eod writer =
726726+ match encode' ?buf codec value ~eod writer with
727727+ | Ok () -> Ok ()
728728+ | Error e -> Error (Init.Error.to_string e)
729729+730730+let encode_string' ?buf:_ codec value =
731731+ let buffer = Buffer.create 1024 in
732732+ match encode_to_buffer buffer codec value with
733733+ | Error e -> Error e
734734+ | Ok () -> Ok (Buffer.contents buffer)
735735+736736+let encode_string ?buf codec value =
737737+ match encode_string' ?buf codec value with
738738+ | Ok s -> Ok s
739739+ | Error e -> Error (Init.Error.to_string e)
+121
src/bytesrw/init_bytesrw.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** INI parser and encoder using bytesrw.
77+88+ Implements Python configparser semantics including:
99+ - Multiline values via indentation
1010+ - Basic interpolation: [%(name)s]
1111+ - Extended interpolation: [$\{section:name\}]
1212+ - DEFAULT section inheritance
1313+ - Case-insensitive option lookup
1414+1515+ See notes about {{!layout}layout preservation}. *)
1616+1717+open Bytesrw
1818+1919+(** {1:config Configuration} *)
2020+2121+type interpolation =
2222+ | No_interpolation (** RawConfigParser behavior. *)
2323+ | Basic_interpolation (** [%(name)s] syntax. *)
2424+ | Extended_interpolation (** [$\{section:name\}] syntax. *)
2525+(** The type for interpolation modes. *)
2626+2727+type config = {
2828+ delimiters : string list;
2929+ (** Key-value delimiters. Default: [["="; ":"]]. *)
3030+3131+ comment_prefixes : string list;
3232+ (** Full-line comment prefixes. Default: [["#"; ";"]]. *)
3333+3434+ inline_comment_prefixes : string list;
3535+ (** Inline comment prefixes (require preceding whitespace).
3636+ Default: [[]] (disabled). *)
3737+3838+ default_section : string;
3939+ (** Name of the default section. Default: ["DEFAULT"]. *)
4040+4141+ interpolation : interpolation;
4242+ (** Interpolation mode. Default: {!Basic_interpolation}. *)
4343+4444+ allow_no_value : bool;
4545+ (** Allow options without values. Default: [false]. *)
4646+4747+ strict : bool;
4848+ (** Error on duplicate sections/options. Default: [true]. *)
4949+5050+ empty_lines_in_values : bool;
5151+ (** Allow empty lines in multiline values. Default: [true]. *)
5252+}
5353+(** The type for parser configuration. *)
5454+5555+val default_config : config
5656+(** [default_config] is the default configuration matching Python's
5757+ [configparser.ConfigParser]. *)
5858+5959+val raw_config : config
6060+(** [raw_config] is configuration with no interpolation, matching
6161+ Python's [configparser.RawConfigParser]. *)
6262+6363+(** {1:decode Decode} *)
6464+6565+val decode :
6666+ ?config:config -> ?locs:bool -> ?layout:bool -> ?file:Init.Textloc.fpath ->
6767+ 'a Init.t -> Bytes.Reader.t -> ('a, string) result
6868+(** [decode codec r] decodes a value from [r] according to [codec].
6969+ {ul
7070+ {- [config] is the parser configuration. Defaults to {!default_config}.}
7171+ {- If [locs] is [true] locations are preserved in metadata.
7272+ Defaults to [false].}
7373+ {- If [layout] is [true] whitespace is preserved in metadata.
7474+ Defaults to [false].}
7575+ {- [file] is the file path for error messages.
7676+ Defaults to {!Init.Textloc.file_none}.}} *)
7777+7878+val decode' :
7979+ ?config:config -> ?locs:bool -> ?layout:bool -> ?file:Init.Textloc.fpath ->
8080+ 'a Init.t -> Bytes.Reader.t -> ('a, Init.Error.t) result
8181+(** [decode'] is like {!val-decode} but preserves the error structure. *)
8282+8383+val decode_string :
8484+ ?config:config -> ?locs:bool -> ?layout:bool -> ?file:Init.Textloc.fpath ->
8585+ 'a Init.t -> string -> ('a, string) result
8686+(** [decode_string] is like {!val-decode} but decodes from a string. *)
8787+8888+val decode_string' :
8989+ ?config:config -> ?locs:bool -> ?layout:bool -> ?file:Init.Textloc.fpath ->
9090+ 'a Init.t -> string -> ('a, Init.Error.t) result
9191+(** [decode_string'] is like {!val-decode'} but decodes from a string. *)
9292+9393+(** {1:encode Encode} *)
9494+9595+val encode :
9696+ ?buf:Bytes.t -> 'a Init.t -> 'a -> eod:bool -> Bytes.Writer.t ->
9797+ (unit, string) result
9898+(** [encode codec v w] encodes [v] according to [codec] on [w].
9999+ {ul
100100+ {- [buf] is an optional buffer for writing.}
101101+ {- [eod] indicates whether to write end-of-data.}} *)
102102+103103+val encode' :
104104+ ?buf:Bytes.t -> 'a Init.t -> 'a -> eod:bool -> Bytes.Writer.t ->
105105+ (unit, Init.Error.t) result
106106+(** [encode'] is like {!val-encode} but preserves the error structure. *)
107107+108108+val encode_string :
109109+ ?buf:Bytes.t -> 'a Init.t -> 'a -> (string, string) result
110110+(** [encode_string] is like {!val-encode} but writes to a string. *)
111111+112112+val encode_string' :
113113+ ?buf:Bytes.t -> 'a Init.t -> 'a -> (string, Init.Error.t) result
114114+(** [encode_string'] is like {!val-encode'} but writes to a string. *)
115115+116116+(** {1:layout Layout preservation}
117117+118118+ When [layout:true] is passed to decode functions, whitespace and
119119+ comments are preserved in {!Init.Meta.t} values. This enables
120120+ layout-preserving round-trips where the original formatting is
121121+ maintained as much as possible. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Eio integration for Init INI parser.
77+88+ Provides file system operations using Eio paths. *)
99+1010+(* ---- Eio Exception Integration ---- *)
1111+1212+type Eio.Exn.err += E of Init.Error.t
1313+1414+let err e = Eio.Exn.create (E e)
1515+1616+let () = Eio.Exn.register_pp (fun fmt -> function
1717+ | E e -> Format.fprintf fmt "%a" Init.Error.pp e; true
1818+ | _ -> false)
1919+2020+(* ---- Path Operations ---- *)
2121+2222+let decode_path ?(config=Init_bytesrw.default_config) ?(locs=false) ?(layout=false)
2323+ codec path =
2424+ let file = Eio.Path.native_exn path in
2525+ Eio.Path.with_open_in path @@ fun flow ->
2626+ let reader = Bytesrw_eio.bytes_reader_of_flow flow in
2727+ Init_bytesrw.decode' ~config ~locs ~layout ~file codec reader
2828+2929+let decode_path_exn ?config ?locs ?layout codec path =
3030+ match decode_path ?config ?locs ?layout codec path with
3131+ | Ok v -> v
3232+ | Error e -> raise (err e)
3333+3434+let encode_path ?buf codec value path =
3535+ Eio.Path.with_open_out ~create:(`Or_truncate 0o644) path @@ fun flow ->
3636+ let writer = Bytesrw_eio.bytes_writer_of_flow flow in
3737+ match Init_bytesrw.encode' ?buf codec value ~eod:true writer with
3838+ | Ok () -> Ok ()
3939+ | Error e -> Error e
4040+4141+let encode_path_exn ?buf codec value path =
4242+ match encode_path ?buf codec value path with
4343+ | Ok () -> ()
4444+ | Error e -> raise (err e)
4545+4646+(* ---- Flow Operations ---- *)
4747+4848+let decode_flow ?(config=Init_bytesrw.default_config) ?(locs=false) ?(layout=false)
4949+ ?file codec flow =
5050+ let reader = Bytesrw_eio.bytes_reader_of_flow flow in
5151+ Init_bytesrw.decode' ~config ~locs ~layout ?file codec reader
5252+5353+let decode_flow_exn ?config ?locs ?layout ?file codec flow =
5454+ match decode_flow ?config ?locs ?layout ?file codec flow with
5555+ | Ok v -> v
5656+ | Error e -> raise (err e)
5757+5858+let encode_flow ?buf codec value ~eod flow =
5959+ let writer = Bytesrw_eio.bytes_writer_of_flow flow in
6060+ Init_bytesrw.encode' ?buf codec value ~eod writer
6161+6262+let encode_flow_exn ?buf codec value ~eod flow =
6363+ match encode_flow ?buf codec value ~eod flow with
6464+ | Ok () -> ()
6565+ | Error e -> raise (err e)
+59
src/eio/init_eio.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Eio integration for Init INI parser.
77+88+ Provides file system operations using Eio paths and flows. *)
99+1010+(** {1:errors Error handling} *)
1111+1212+type Eio.Exn.err += E of Init.Error.t
1313+(** Eio exception for Init errors. *)
1414+1515+val err : Init.Error.t -> exn
1616+(** [err e] creates an Eio exception from [e]. *)
1717+1818+(** {1:paths Path operations} *)
1919+2020+val decode_path :
2121+ ?config:Init_bytesrw.config -> ?locs:bool -> ?layout:bool ->
2222+ 'a Init.t -> _ Eio.Path.t -> ('a, Init.Error.t) result
2323+(** [decode_path codec path] reads and decodes the file at [path]. *)
2424+2525+val decode_path_exn :
2626+ ?config:Init_bytesrw.config -> ?locs:bool -> ?layout:bool ->
2727+ 'a Init.t -> _ Eio.Path.t -> 'a
2828+(** [decode_path_exn] is like {!decode_path} but raises on error. *)
2929+3030+val encode_path :
3131+ ?buf:bytes -> 'a Init.t -> 'a -> _ Eio.Path.t ->
3232+ (unit, Init.Error.t) result
3333+(** [encode_path codec v path] encodes [v] and writes to [path]. *)
3434+3535+val encode_path_exn :
3636+ ?buf:bytes -> 'a Init.t -> 'a -> _ Eio.Path.t -> unit
3737+(** [encode_path_exn] is like {!encode_path} but raises on error. *)
3838+3939+(** {1:flows Flow operations} *)
4040+4141+val decode_flow :
4242+ ?config:Init_bytesrw.config -> ?locs:bool -> ?layout:bool ->
4343+ ?file:Init.Textloc.fpath -> 'a Init.t -> _ Eio.Flow.source ->
4444+ ('a, Init.Error.t) result
4545+(** [decode_flow codec flow] decodes from [flow]. *)
4646+4747+val decode_flow_exn :
4848+ ?config:Init_bytesrw.config -> ?locs:bool -> ?layout:bool ->
4949+ ?file:Init.Textloc.fpath -> 'a Init.t -> _ Eio.Flow.source -> 'a
5050+(** [decode_flow_exn] is like {!decode_flow} but raises on error. *)
5151+5252+val encode_flow :
5353+ ?buf:bytes -> 'a Init.t -> 'a -> eod:bool -> _ Eio.Flow.sink ->
5454+ (unit, Init.Error.t) result
5555+(** [encode_flow codec v flow] encodes [v] to [flow]. *)
5656+5757+val encode_flow_exn :
5858+ ?buf:bytes -> 'a Init.t -> 'a -> eod:bool -> _ Eio.Flow.sink -> unit
5959+(** [encode_flow_exn] is like {!encode_flow} but raises on error. *)
+875
src/init.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Declarative INI data manipulation for OCaml.
77+88+ Init provides bidirectional codecs for INI files following Python's
99+ configparser semantics. *)
1010+1111+type 'a fmt = Format.formatter -> 'a -> unit
1212+1313+(* ---- Text Locations ---- *)
1414+1515+module Textloc = struct
1616+ type fpath = string
1717+ let file_none = "-"
1818+1919+ type byte_pos = int
2020+ let byte_pos_none = -1
2121+2222+ type line_num = int
2323+ let line_num_none = -1
2424+2525+ type line_pos = line_num * byte_pos
2626+ let line_pos_first = (1, 0)
2727+ let line_pos_none = (line_num_none, byte_pos_none)
2828+2929+ type t = {
3030+ file : fpath;
3131+ first_byte : byte_pos;
3232+ last_byte : byte_pos;
3333+ first_line : line_pos;
3434+ last_line : line_pos;
3535+ }
3636+3737+ let none = {
3838+ file = file_none;
3939+ first_byte = byte_pos_none;
4040+ last_byte = byte_pos_none;
4141+ first_line = line_pos_none;
4242+ last_line = line_pos_none;
4343+ }
4444+4545+ let make ~file ~first_byte ~last_byte ~first_line ~last_line =
4646+ { file; first_byte; last_byte; first_line; last_line }
4747+4848+ let file t = t.file
4949+ let set_file t file = { t with file }
5050+ let first_byte t = t.first_byte
5151+ let last_byte t = t.last_byte
5252+ let first_line t = t.first_line
5353+ let last_line t = t.last_line
5454+5555+ let is_none t = t.first_byte < 0
5656+ let is_empty t = t.first_byte > t.last_byte
5757+5858+ let equal t0 t1 =
5959+ String.equal t0.file t1.file &&
6060+ t0.first_byte = t1.first_byte &&
6161+ t0.last_byte = t1.last_byte
6262+6363+ let compare t0 t1 =
6464+ let c = String.compare t0.file t1.file in
6565+ if c <> 0 then c else
6666+ let c = Int.compare t0.first_byte t1.first_byte in
6767+ if c <> 0 then c else
6868+ Int.compare t0.last_byte t1.last_byte
6969+7070+ let set_first t ~first_byte ~first_line =
7171+ { t with first_byte; first_line }
7272+7373+ let set_last t ~last_byte ~last_line =
7474+ { t with last_byte; last_line }
7575+7676+ let to_first t =
7777+ { t with last_byte = t.first_byte; last_line = t.first_line }
7878+7979+ let to_last t =
8080+ { t with first_byte = t.last_byte; first_line = t.last_line }
8181+8282+ let before t =
8383+ { t with last_byte = t.first_byte - 1; last_line = t.first_line }
8484+8585+ let after t =
8686+ { t with first_byte = t.last_byte + 1; first_line = t.last_line }
8787+8888+ let span t0 t1 =
8989+ let first_byte, first_line, last_byte, last_line, file =
9090+ if t0.first_byte <= t1.first_byte then
9191+ if t0.last_byte >= t1.last_byte then
9292+ t0.first_byte, t0.first_line, t0.last_byte, t0.last_line, t0.file
9393+ else
9494+ t0.first_byte, t0.first_line, t1.last_byte, t1.last_line, t1.file
9595+ else
9696+ if t1.last_byte >= t0.last_byte then
9797+ t1.first_byte, t1.first_line, t1.last_byte, t1.last_line, t1.file
9898+ else
9999+ t1.first_byte, t1.first_line, t0.last_byte, t0.last_line, t0.file
100100+ in
101101+ { file; first_byte; last_byte; first_line; last_line }
102102+103103+ let reloc ~first ~last =
104104+ { file = last.file;
105105+ first_byte = first.first_byte;
106106+ first_line = first.first_line;
107107+ last_byte = last.last_byte;
108108+ last_line = last.last_line }
109109+110110+ let pp_ocaml ppf t =
111111+ let l, c = t.first_line in
112112+ let el, ec = t.last_line in
113113+ if is_none t then Format.fprintf ppf "%s" t.file
114114+ else if is_empty t then
115115+ Format.fprintf ppf "%s:%d:%d" t.file l (t.first_byte - c)
116116+ else if l = el then
117117+ Format.fprintf ppf "%s:%d:%d-%d" t.file l (t.first_byte - c) (t.last_byte - ec)
118118+ else
119119+ Format.fprintf ppf "%s:%d:%d-%d:%d" t.file l (t.first_byte - c) el (t.last_byte - ec)
120120+121121+ let pp_gnu ppf t =
122122+ let l, c = t.first_line in
123123+ if is_none t then Format.fprintf ppf "%s" t.file
124124+ else Format.fprintf ppf "%s:%d.%d" t.file l (t.first_byte - c + 1)
125125+126126+ let pp = pp_ocaml
127127+128128+ let pp_dump ppf t =
129129+ Format.fprintf ppf "@[<h>{file=%S;@ first_byte=%d;@ last_byte=%d;@ \
130130+ first_line=(%d,%d);@ last_line=(%d,%d)}@]"
131131+ t.file t.first_byte t.last_byte
132132+ (fst t.first_line) (snd t.first_line)
133133+ (fst t.last_line) (snd t.last_line)
134134+end
135135+136136+(* ---- Metadata ---- *)
137137+138138+module Meta = struct
139139+ type t = {
140140+ textloc : Textloc.t;
141141+ ws_before : string;
142142+ ws_after : string;
143143+ comment : string option; (* Associated comment *)
144144+ }
145145+146146+ let none = {
147147+ textloc = Textloc.none;
148148+ ws_before = "";
149149+ ws_after = "";
150150+ comment = None;
151151+ }
152152+153153+ let make ?(ws_before = "") ?(ws_after = "") ?comment textloc =
154154+ { textloc; ws_before; ws_after; comment }
155155+156156+ let is_none t = Textloc.is_none t.textloc
157157+ let textloc t = t.textloc
158158+ let ws_before t = t.ws_before
159159+ let ws_after t = t.ws_after
160160+ let comment t = t.comment
161161+162162+ let with_textloc t textloc = { t with textloc }
163163+ let with_ws_before t ws_before = { t with ws_before }
164164+ let with_ws_after t ws_after = { t with ws_after }
165165+ let with_comment t comment = { t with comment }
166166+167167+ let clear_ws t = { t with ws_before = ""; ws_after = "" }
168168+ let clear_textloc t = { t with textloc = Textloc.none }
169169+170170+ let copy_ws src ~dst =
171171+ { dst with ws_before = src.ws_before; ws_after = src.ws_after }
172172+end
173173+174174+type 'a node = 'a * Meta.t
175175+176176+(* ---- Paths ---- *)
177177+178178+module Path = struct
179179+ type index =
180180+ | Section of string node
181181+ | Option of string node
182182+183183+ let pp_index ppf = function
184184+ | Section (s, _) -> Format.fprintf ppf "[%s]" s
185185+ | Option (s, _) -> Format.fprintf ppf "%s" s
186186+187187+ type t = index list (* Reversed *)
188188+189189+ let root = []
190190+ let is_root = function [] -> true | _ -> false
191191+192192+ let section ?(meta = Meta.none) name path = Section (name, meta) :: path
193193+ let option ?(meta = Meta.none) name path = Option (name, meta) :: path
194194+195195+ let rev_indices t = t
196196+197197+ let pp ppf t =
198198+ let rec loop = function
199199+ | [] -> ()
200200+ | [i] -> pp_index ppf i
201201+ | i :: rest -> loop rest; Format.fprintf ppf "/"; pp_index ppf i
202202+ in
203203+ loop (List.rev t)
204204+end
205205+206206+(* ---- Errors ---- *)
207207+208208+module Error = struct
209209+ type kind =
210210+ | Parse of string
211211+ | Codec of string
212212+ | Missing_section of string
213213+ | Missing_option of { section : string; option : string }
214214+ | Duplicate_section of string
215215+ | Duplicate_option of { section : string; option : string }
216216+ | Type_mismatch of { expected : string; got : string }
217217+ | Interpolation of { option : string; reason : string }
218218+ | Unknown_option of string
219219+ | Unknown_section of string
220220+221221+ type t = {
222222+ kind : kind;
223223+ meta : Meta.t;
224224+ path : Path.t;
225225+ }
226226+227227+ let make ?(meta = Meta.none) ?(path = Path.root) kind =
228228+ { kind; meta; path }
229229+230230+ let kind e = e.kind
231231+ let meta e = e.meta
232232+ let path e = e.path
233233+234234+ exception Error of t
235235+236236+ let raise ?meta ?path kind = raise (Error (make ?meta ?path kind))
237237+238238+ let kind_to_string = function
239239+ | Parse msg -> Printf.sprintf "parse error: %s" msg
240240+ | Codec msg -> Printf.sprintf "codec error: %s" msg
241241+ | Missing_section name -> Printf.sprintf "missing section: [%s]" name
242242+ | Missing_option { section; option } ->
243243+ Printf.sprintf "missing option '%s' in section [%s]" option section
244244+ | Duplicate_section name -> Printf.sprintf "duplicate section: [%s]" name
245245+ | Duplicate_option { section; option } ->
246246+ Printf.sprintf "duplicate option '%s' in section [%s]" option section
247247+ | Type_mismatch { expected; got } ->
248248+ Printf.sprintf "type mismatch: expected %s, got %s" expected got
249249+ | Interpolation { option; reason } ->
250250+ Printf.sprintf "interpolation error in '%s': %s" option reason
251251+ | Unknown_option name -> Printf.sprintf "unknown option: %s" name
252252+ | Unknown_section name -> Printf.sprintf "unknown section: [%s]" name
253253+254254+ let to_string e =
255255+ let loc = if Meta.is_none e.meta then "" else
256256+ Format.asprintf "%a: " Textloc.pp (Meta.textloc e.meta)
257257+ in
258258+ let path = if Path.is_root e.path then "" else
259259+ Format.asprintf " at %a" Path.pp e.path
260260+ in
261261+ Printf.sprintf "%s%s%s" loc (kind_to_string e.kind) path
262262+263263+ let pp ppf e = Format.pp_print_string ppf (to_string e)
264264+end
265265+266266+(* ---- Codec Types ---- *)
267267+268268+(* Internal representation for codec implementations *)
269269+module Repr = struct
270270+ (* A decoded INI value with metadata *)
271271+ type ini_value = {
272272+ raw : string; (* Raw string value *)
273273+ interpolated : string; (* After interpolation *)
274274+ meta : Meta.t;
275275+ }
276276+277277+ (* A section's options *)
278278+ type ini_section = {
279279+ name : string node;
280280+ options : (string node * ini_value) list;
281281+ meta : Meta.t; (* Section header metadata *)
282282+ }
283283+284284+ (* A full INI document *)
285285+ type ini_doc = {
286286+ defaults : (string node * ini_value) list;
287287+ sections : ini_section list;
288288+ meta : Meta.t; (* Document metadata *)
289289+ }
290290+291291+ (* Codec error during decode/encode *)
292292+ type 'a codec_result = ('a, Error.t) result
293293+294294+ (* Section decoder state *)
295295+ type 'dec section_state = {
296296+ decode : ini_section -> 'dec codec_result;
297297+ encode : 'dec -> ini_section;
298298+ known_options : string list;
299299+ unknown_handler : [ `Skip | `Error | `Keep ];
300300+ }
301301+302302+ (* Document decoder state *)
303303+ type 'dec document_state = {
304304+ decode : ini_doc -> 'dec codec_result;
305305+ encode : 'dec -> ini_doc;
306306+ known_sections : string list;
307307+ unknown_handler : [ `Skip | `Error ];
308308+ }
309309+end
310310+311311+(* The abstract codec type *)
312312+type 'a t = {
313313+ kind : string;
314314+ doc : string;
315315+ (* Value-level decode/encode (for individual option values) *)
316316+ dec : Repr.ini_value -> ('a, Error.t) result;
317317+ enc : 'a -> Meta.t -> Repr.ini_value;
318318+ (* Section-level decode/encode (for Section.finish) *)
319319+ section : 'a Repr.section_state option;
320320+ (* Document-level decode/encode (for Document.finish) *)
321321+ document : 'a Repr.document_state option;
322322+}
323323+324324+let kind c = c.kind
325325+let doc c = c.doc
326326+327327+let with_doc ?kind:k ?doc:d c =
328328+ { c with
329329+ kind = Option.value ~default:c.kind k;
330330+ doc = Option.value ~default:c.doc d }
331331+332332+let section_state c = c.section
333333+let document_state c = c.document
334334+335335+(* ---- Base Codecs ---- *)
336336+337337+let make_value_codec ~kind ~doc ~dec ~enc = {
338338+ kind; doc; dec; enc;
339339+ section = None;
340340+ document = None;
341341+}
342342+343343+let string = make_value_codec
344344+ ~kind:"string"
345345+ ~doc:""
346346+ ~dec:(fun v -> Ok v.Repr.interpolated)
347347+ ~enc:(fun s meta -> { Repr.raw = s; interpolated = s; meta })
348348+349349+let int = make_value_codec
350350+ ~kind:"integer"
351351+ ~doc:""
352352+ ~dec:(fun v ->
353353+ match int_of_string_opt v.Repr.interpolated with
354354+ | Some i -> Ok i
355355+ | None -> Error (Error.make (Type_mismatch {
356356+ expected = "integer"; got = v.interpolated })))
357357+ ~enc:(fun i meta ->
358358+ let s = Int.to_string i in
359359+ { Repr.raw = s; interpolated = s; meta })
360360+361361+let int32 = make_value_codec
362362+ ~kind:"int32"
363363+ ~doc:""
364364+ ~dec:(fun v ->
365365+ match Int32.of_string_opt v.Repr.interpolated with
366366+ | Some i -> Ok i
367367+ | None -> Error (Error.make (Type_mismatch {
368368+ expected = "int32"; got = v.interpolated })))
369369+ ~enc:(fun i meta ->
370370+ let s = Int32.to_string i in
371371+ { Repr.raw = s; interpolated = s; meta })
372372+373373+let int64 = make_value_codec
374374+ ~kind:"int64"
375375+ ~doc:""
376376+ ~dec:(fun v ->
377377+ match Int64.of_string_opt v.Repr.interpolated with
378378+ | Some i -> Ok i
379379+ | None -> Error (Error.make (Type_mismatch {
380380+ expected = "int64"; got = v.interpolated })))
381381+ ~enc:(fun i meta ->
382382+ let s = Int64.to_string i in
383383+ { Repr.raw = s; interpolated = s; meta })
384384+385385+let float = make_value_codec
386386+ ~kind:"float"
387387+ ~doc:""
388388+ ~dec:(fun v ->
389389+ match float_of_string_opt v.Repr.interpolated with
390390+ | Some f -> Ok f
391391+ | None -> Error (Error.make (Type_mismatch {
392392+ expected = "float"; got = v.interpolated })))
393393+ ~enc:(fun f meta ->
394394+ let s = Float.to_string f in
395395+ { Repr.raw = s; interpolated = s; meta })
396396+397397+(* Python configparser-compatible boolean parsing *)
398398+let parse_bool s =
399399+ match String.lowercase_ascii s with
400400+ | "1" | "yes" | "true" | "on" -> Some true
401401+ | "0" | "no" | "false" | "off" -> Some false
402402+ | _ -> None
403403+404404+let bool = make_value_codec
405405+ ~kind:"boolean"
406406+ ~doc:"Accepts: 1/yes/true/on (true), 0/no/false/off (false)"
407407+ ~dec:(fun v ->
408408+ match parse_bool v.Repr.interpolated with
409409+ | Some b -> Ok b
410410+ | None -> Error (Error.make (Type_mismatch {
411411+ expected = "boolean (yes/no/true/false/on/off/1/0)";
412412+ got = v.interpolated })))
413413+ ~enc:(fun b meta ->
414414+ let s = if b then "true" else "false" in
415415+ { Repr.raw = s; interpolated = s; meta })
416416+417417+let bool_01 = make_value_codec
418418+ ~kind:"boolean (0/1)"
419419+ ~doc:""
420420+ ~dec:(fun v ->
421421+ match v.Repr.interpolated with
422422+ | "1" -> Ok true
423423+ | "0" -> Ok false
424424+ | s -> Error (Error.make (Type_mismatch { expected = "0 or 1"; got = s })))
425425+ ~enc:(fun b meta ->
426426+ let s = if b then "1" else "0" in
427427+ { Repr.raw = s; interpolated = s; meta })
428428+429429+let bool_yesno = make_value_codec
430430+ ~kind:"boolean (yes/no)"
431431+ ~doc:""
432432+ ~dec:(fun v ->
433433+ match String.lowercase_ascii v.Repr.interpolated with
434434+ | "yes" -> Ok true
435435+ | "no" -> Ok false
436436+ | s -> Error (Error.make (Type_mismatch { expected = "yes or no"; got = s })))
437437+ ~enc:(fun b meta ->
438438+ let s = if b then "yes" else "no" in
439439+ { Repr.raw = s; interpolated = s; meta })
440440+441441+let bool_truefalse = make_value_codec
442442+ ~kind:"boolean (true/false)"
443443+ ~doc:""
444444+ ~dec:(fun v ->
445445+ match String.lowercase_ascii v.Repr.interpolated with
446446+ | "true" -> Ok true
447447+ | "false" -> Ok false
448448+ | s -> Error (Error.make (Type_mismatch {
449449+ expected = "true or false"; got = s })))
450450+ ~enc:(fun b meta ->
451451+ let s = if b then "true" else "false" in
452452+ { Repr.raw = s; interpolated = s; meta })
453453+454454+let bool_onoff = make_value_codec
455455+ ~kind:"boolean (on/off)"
456456+ ~doc:""
457457+ ~dec:(fun v ->
458458+ match String.lowercase_ascii v.Repr.interpolated with
459459+ | "on" -> Ok true
460460+ | "off" -> Ok false
461461+ | s -> Error (Error.make (Type_mismatch { expected = "on or off"; got = s })))
462462+ ~enc:(fun b meta ->
463463+ let s = if b then "on" else "off" in
464464+ { Repr.raw = s; interpolated = s; meta })
465465+466466+(* ---- Combinators ---- *)
467467+468468+let map ?kind:k ?doc:d ~dec ~enc c =
469469+ let kind = Option.value ~default:c.kind k in
470470+ let doc = Option.value ~default:c.doc d in
471471+ { kind; doc;
472472+ dec = (fun v -> Result.map dec (c.dec v));
473473+ enc = (fun x meta -> c.enc (enc x) meta);
474474+ section = None;
475475+ document = None;
476476+ }
477477+478478+let enum ?cmp ?kind ?doc assoc =
479479+ let cmp = Option.value ~default:Stdlib.compare cmp in
480480+ let kind = Option.value ~default:"enum" kind in
481481+ let doc = Option.value ~default:"" doc in
482482+ let lc_assoc = List.map (fun (k, v) -> (String.lowercase_ascii k, v)) assoc in
483483+ let rev_assoc = List.map (fun (s, v) -> (v, s)) assoc in
484484+ make_value_codec ~kind ~doc
485485+ ~dec:(fun v ->
486486+ match List.assoc_opt (String.lowercase_ascii v.Repr.interpolated) lc_assoc with
487487+ | Some x -> Ok x
488488+ | None -> Error (Error.make (Type_mismatch {
489489+ expected = kind; got = v.interpolated })))
490490+ ~enc:(fun x meta ->
491491+ match List.find_opt (fun (v', _) -> cmp x v' = 0) rev_assoc with
492492+ | Some (_, s) -> { Repr.raw = s; interpolated = s; meta }
493493+ | None -> failwith "enum value not in association list")
494494+495495+let option ?kind ?doc c =
496496+ let kind = Option.value ~default:("optional " ^ c.kind) kind in
497497+ let doc = Option.value ~default:c.doc doc in
498498+ { kind; doc;
499499+ dec = (fun v ->
500500+ if v.Repr.interpolated = "" then Ok None
501501+ else Result.map Option.some (c.dec v));
502502+ enc = (function
503503+ | Some x -> c.enc x
504504+ | None -> fun meta -> { Repr.raw = ""; interpolated = ""; meta });
505505+ section = None;
506506+ document = None;
507507+ }
508508+509509+let default def c = {
510510+ c with
511511+ dec = (fun v ->
512512+ match c.dec v with
513513+ | Ok x -> Ok x
514514+ | Error _ -> Ok def);
515515+}
516516+517517+let list ?(sep = ',') c = {
518518+ kind = "list of " ^ c.kind;
519519+ doc = "";
520520+ dec = (fun v ->
521521+ if v.Repr.interpolated = "" then Ok []
522522+ else
523523+ let parts = String.split_on_char sep v.Repr.interpolated in
524524+ let parts = List.map String.trim parts in
525525+ let rec decode_all acc = function
526526+ | [] -> Ok (List.rev acc)
527527+ | part :: rest ->
528528+ let pv = { v with Repr.raw = part; interpolated = part } in
529529+ match c.dec pv with
530530+ | Ok x -> decode_all (x :: acc) rest
531531+ | Error e -> Error e
532532+ in
533533+ decode_all [] parts);
534534+ enc = (fun xs meta ->
535535+ let parts = List.map (fun x -> (c.enc x meta).Repr.interpolated) xs in
536536+ let s = String.concat (String.make 1 sep ^ " ") parts in
537537+ { Repr.raw = s; interpolated = s; meta });
538538+ section = None;
539539+ document = None;
540540+}
541541+542542+(* ---- Section Codecs ---- *)
543543+544544+module Section = struct
545545+ type 'a codec = 'a t
546546+547547+ type ('o, 'dec) map = {
548548+ kind : string;
549549+ doc : string;
550550+ decode : Repr.ini_section -> 'dec Repr.codec_result;
551551+ encode : 'o -> Repr.ini_section;
552552+ known : string list;
553553+ unknown : [ `Skip | `Error | `Keep ];
554554+ }
555555+556556+ let obj ?kind ?doc (f : 'dec) : ('o, 'dec) map =
557557+ let kind = Option.value ~default:"section" kind in
558558+ let doc = Option.value ~default:"" doc in
559559+ {
560560+ kind; doc;
561561+ decode = (fun _ -> Ok f);
562562+ encode = (fun _ -> {
563563+ Repr.name = ("", Meta.none);
564564+ options = [];
565565+ meta = Meta.none;
566566+ });
567567+ known = [];
568568+ unknown = `Skip;
569569+ }
570570+571571+ let mem ?doc:_ ?dec_absent ?enc ?enc_omit name (c : 'a codec)
572572+ (m : ('o, 'a -> 'dec) map) : ('o, 'dec) map =
573573+ let lc_name = String.lowercase_ascii name in
574574+ {
575575+ m with
576576+ known = lc_name :: m.known;
577577+ decode = (fun sec ->
578578+ let opt = List.find_opt (fun ((n, _), _) ->
579579+ String.lowercase_ascii n = lc_name) sec.Repr.options in
580580+ let decoded = match opt with
581581+ | Some (_, v) -> c.dec v
582582+ | None ->
583583+ match dec_absent with
584584+ | Some def -> Ok def
585585+ | None -> Error (Error.make (Missing_option {
586586+ section = fst sec.name; option = name }))
587587+ in
588588+ match decoded with
589589+ | Ok a ->
590590+ (match m.decode sec with
591591+ | Ok f -> Ok (f a)
592592+ | Error e -> Error e)
593593+ | Error e -> Error e);
594594+ encode = (fun o ->
595595+ let sec = m.encode o in
596596+ match enc with
597597+ | None -> sec
598598+ | Some enc_fn ->
599599+ let v = enc_fn o in
600600+ let should_omit = match enc_omit with
601601+ | Some f -> f v
602602+ | None -> false
603603+ in
604604+ if should_omit then sec
605605+ else
606606+ let iv = c.enc v Meta.none in
607607+ { sec with options = ((name, Meta.none), iv) :: sec.options });
608608+ }
609609+610610+ let opt_mem ?doc ?enc name c m =
611611+ let opt_c = option c in
612612+ let enc' = Option.map (fun f o -> f o) enc in
613613+ mem ?doc ~dec_absent:None ?enc:enc' ~enc_omit:Option.is_none name opt_c m
614614+615615+ let skip_unknown m = { m with unknown = `Skip }
616616+ let error_unknown m = { m with unknown = `Error }
617617+618618+ let keep_unknown ?enc (m : ('o, (string * string) list -> 'dec) map)
619619+ : ('o, 'dec) map =
620620+ {
621621+ kind = m.kind;
622622+ doc = m.doc;
623623+ known = m.known;
624624+ unknown = `Keep;
625625+ decode = (fun sec ->
626626+ let unknown_opts = List.filter_map (fun ((n, _), v) ->
627627+ let lc_n = String.lowercase_ascii n in
628628+ if List.mem lc_n m.known then None
629629+ else Some (n, v.Repr.interpolated)
630630+ ) sec.Repr.options in
631631+ match m.decode sec with
632632+ | Ok f -> Ok (f unknown_opts)
633633+ | Error e -> Error e);
634634+ encode = (fun o ->
635635+ let sec = m.encode o in
636636+ match enc with
637637+ | None -> sec
638638+ | Some enc_fn ->
639639+ let unknown_opts = enc_fn o in
640640+ let new_opts = List.map (fun (k, v) ->
641641+ ((k, Meta.none), { Repr.raw = v; interpolated = v; meta = Meta.none })
642642+ ) unknown_opts in
643643+ { sec with options = new_opts @ sec.options });
644644+ }
645645+646646+ let finish (m : ('o, 'o) map) : 'o codec =
647647+ let section_state : 'o Repr.section_state = {
648648+ decode = (fun sec ->
649649+ (* Check for unknown options *)
650650+ (match m.unknown with
651651+ | `Skip -> ()
652652+ | `Keep -> ()
653653+ | `Error ->
654654+ List.iter (fun ((n, _), _) ->
655655+ let lc_n = String.lowercase_ascii n in
656656+ if not (List.mem lc_n m.known) then
657657+ Error.raise (Unknown_option n)
658658+ ) sec.Repr.options);
659659+ m.decode sec);
660660+ encode = (fun o ->
661661+ let sec = m.encode o in
662662+ { sec with options = List.rev sec.options });
663663+ known_options = m.known;
664664+ unknown_handler = m.unknown;
665665+ } in
666666+ {
667667+ kind = m.kind;
668668+ doc = m.doc;
669669+ dec = (fun _ -> Error (Error.make (Codec "section codec requires section-level decode")));
670670+ enc = (fun _ _ -> { Repr.raw = ""; interpolated = ""; meta = Meta.none });
671671+ section = Some section_state;
672672+ document = None;
673673+ }
674674+end
675675+676676+(* ---- Document Codecs ---- *)
677677+678678+module Document = struct
679679+ type 'a codec = 'a t
680680+681681+ type ('o, 'dec) map = {
682682+ kind : string;
683683+ doc : string;
684684+ decode : Repr.ini_doc -> 'dec Repr.codec_result;
685685+ encode : 'o -> Repr.ini_doc;
686686+ known : string list;
687687+ unknown : [ `Skip | `Error ];
688688+ }
689689+690690+ let obj ?kind ?doc (f : 'dec) : ('o, 'dec) map =
691691+ let kind = Option.value ~default:"document" kind in
692692+ let doc = Option.value ~default:"" doc in
693693+ {
694694+ kind; doc;
695695+ decode = (fun _ -> Ok f);
696696+ encode = (fun _ -> {
697697+ Repr.defaults = [];
698698+ sections = [];
699699+ meta = Meta.none;
700700+ });
701701+ known = [];
702702+ unknown = `Skip;
703703+ }
704704+705705+ let section ?doc:_ ?enc name (sec_codec : 'a codec)
706706+ (m : ('o, 'a -> 'dec) map) : ('o, 'dec) map =
707707+ let sec_state = match sec_codec.section with
708708+ | Some s -> s
709709+ | None -> failwith "section: codec must be a section codec"
710710+ in
711711+ let lc_name = String.lowercase_ascii name in
712712+ {
713713+ m with
714714+ known = lc_name :: m.known;
715715+ decode = (fun doc ->
716716+ let sec = List.find_opt (fun s ->
717717+ String.lowercase_ascii (fst s.Repr.name) = lc_name) doc.Repr.sections in
718718+ match sec with
719719+ | None -> Error (Error.make (Missing_section name))
720720+ | Some sec ->
721721+ match sec_state.decode sec with
722722+ | Ok a ->
723723+ (match m.decode doc with
724724+ | Ok f -> Ok (f a)
725725+ | Error e -> Error e)
726726+ | Error e -> Error e);
727727+ encode = (fun o ->
728728+ let doc = m.encode o in
729729+ match enc with
730730+ | None -> doc
731731+ | Some enc_fn ->
732732+ let v = enc_fn o in
733733+ let sec = sec_state.encode v in
734734+ let sec = { sec with name = (name, Meta.none) } in
735735+ { doc with sections = sec :: doc.sections });
736736+ }
737737+738738+ let opt_section ?doc:_ ?enc name (sec_codec : 'a codec)
739739+ (m : ('o, 'a option -> 'dec) map) : ('o, 'dec) map =
740740+ let sec_state = match sec_codec.section with
741741+ | Some s -> s
742742+ | None -> failwith "opt_section: codec must be a section codec"
743743+ in
744744+ let lc_name = String.lowercase_ascii name in
745745+ {
746746+ m with
747747+ known = lc_name :: m.known;
748748+ decode = (fun doc ->
749749+ let sec = List.find_opt (fun s ->
750750+ String.lowercase_ascii (fst s.Repr.name) = lc_name) doc.Repr.sections in
751751+ match sec with
752752+ | None ->
753753+ (match m.decode doc with
754754+ | Ok f -> Ok (f None)
755755+ | Error e -> Error e)
756756+ | Some sec ->
757757+ match sec_state.decode sec with
758758+ | Ok a ->
759759+ (match m.decode doc with
760760+ | Ok f -> Ok (f (Some a))
761761+ | Error e -> Error e)
762762+ | Error e -> Error e);
763763+ encode = (fun o ->
764764+ let doc = m.encode o in
765765+ match enc with
766766+ | None -> doc
767767+ | Some enc_fn ->
768768+ match enc_fn o with
769769+ | None -> doc
770770+ | Some v ->
771771+ let sec = sec_state.encode v in
772772+ let sec = { sec with name = (name, Meta.none) } in
773773+ { doc with sections = sec :: doc.sections });
774774+ }
775775+776776+ let defaults ?doc:_ ?enc (sec_codec : 'a codec)
777777+ (m : ('o, 'a -> 'dec) map) : ('o, 'dec) map =
778778+ let sec_state = match sec_codec.section with
779779+ | Some s -> s
780780+ | None -> failwith "defaults: codec must be a section codec"
781781+ in
782782+ {
783783+ m with
784784+ known = "default" :: m.known;
785785+ decode = (fun doc ->
786786+ let fake_sec = {
787787+ Repr.name = ("DEFAULT", Meta.none);
788788+ options = doc.defaults;
789789+ meta = Meta.none;
790790+ } in
791791+ match sec_state.decode fake_sec with
792792+ | Ok a ->
793793+ (match m.decode doc with
794794+ | Ok f -> Ok (f a)
795795+ | Error e -> Error e)
796796+ | Error e -> Error e);
797797+ encode = (fun o ->
798798+ let doc = m.encode o in
799799+ match enc with
800800+ | None -> doc
801801+ | Some enc_fn ->
802802+ let v = enc_fn o in
803803+ let sec = sec_state.encode v in
804804+ { doc with defaults = sec.options });
805805+ }
806806+807807+ let opt_defaults ?doc:_ ?enc (sec_codec : 'a codec)
808808+ (m : ('o, 'a option -> 'dec) map) : ('o, 'dec) map =
809809+ let sec_state = match sec_codec.section with
810810+ | Some s -> s
811811+ | None -> failwith "opt_defaults: codec must be a section codec"
812812+ in
813813+ {
814814+ m with
815815+ known = "default" :: m.known;
816816+ decode = (fun doc ->
817817+ if doc.defaults = [] then
818818+ (match m.decode doc with
819819+ | Ok f -> Ok (f None)
820820+ | Error e -> Error e)
821821+ else
822822+ let fake_sec = {
823823+ Repr.name = ("DEFAULT", Meta.none);
824824+ options = doc.defaults;
825825+ meta = Meta.none;
826826+ } in
827827+ match sec_state.decode fake_sec with
828828+ | Ok a ->
829829+ (match m.decode doc with
830830+ | Ok f -> Ok (f (Some a))
831831+ | Error e -> Error e)
832832+ | Error e -> Error e);
833833+ encode = (fun o ->
834834+ let doc = m.encode o in
835835+ match enc with
836836+ | None -> doc
837837+ | Some enc_fn ->
838838+ match enc_fn o with
839839+ | None -> doc
840840+ | Some v ->
841841+ let sec = sec_state.encode v in
842842+ { doc with defaults = sec.options });
843843+ }
844844+845845+ let skip_unknown m = { m with unknown = `Skip }
846846+ let error_unknown m = { m with unknown = `Error }
847847+848848+ let finish (m : ('o, 'o) map) : 'o codec =
849849+ let document_state : 'o Repr.document_state = {
850850+ decode = (fun doc ->
851851+ (* Check for unknown sections *)
852852+ (match m.unknown with
853853+ | `Skip -> ()
854854+ | `Error ->
855855+ List.iter (fun sec ->
856856+ let lc_n = String.lowercase_ascii (fst sec.Repr.name) in
857857+ if not (List.mem lc_n m.known) then
858858+ Error.raise (Unknown_section (fst sec.name))
859859+ ) doc.Repr.sections);
860860+ m.decode doc);
861861+ encode = (fun o ->
862862+ let doc = m.encode o in
863863+ { doc with sections = List.rev doc.sections });
864864+ known_sections = m.known;
865865+ unknown_handler = m.unknown;
866866+ } in
867867+ {
868868+ kind = m.kind;
869869+ doc = m.doc;
870870+ dec = (fun _ -> Error (Error.make (Codec "document codec requires document-level decode")));
871871+ enc = (fun _ _ -> { Repr.raw = ""; interpolated = ""; meta = Meta.none });
872872+ section = None;
873873+ document = Some document_state;
874874+ }
875875+end
+519
src/init.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Declarative INI data manipulation for OCaml.
77+88+ Init provides bidirectional codecs for INI files following Python's
99+ configparser semantics. The core module has no dependencies.
1010+1111+ {b Features:}
1212+ - Multiline values via indentation
1313+ - Basic interpolation: [%(name)s]
1414+ - Extended interpolation: [$\{section:name\}]
1515+ - DEFAULT section inheritance
1616+ - Case-insensitive option lookup
1717+ - Layout preservation (whitespace and comments)
1818+1919+ {b Sub-libraries:}
2020+ - {!Init_bytesrw} for parsing/encoding with bytesrw
2121+ - {!Init_eio} for Eio file system integration *)
2222+2323+type 'a fmt = Format.formatter -> 'a -> unit
2424+(** The type for formatters. *)
2525+2626+(** {1:textlocs Text Locations} *)
2727+2828+(** Text locations.
2929+3030+ A text location identifies a text span in a given file by an inclusive
3131+ byte position range and the start position on lines. *)
3232+module Textloc : sig
3333+3434+ (** {1:fpath File paths} *)
3535+3636+ type fpath = string
3737+ (** The type for file paths. *)
3838+3939+ val file_none : fpath
4040+ (** [file_none] is ["-"]. A file path for when there is none. *)
4141+4242+ (** {1:pos Positions} *)
4343+4444+ type byte_pos = int
4545+ (** The type for zero-based byte positions in text. *)
4646+4747+ val byte_pos_none : byte_pos
4848+ (** [byte_pos_none] is [-1]. A position to use when there is none. *)
4949+5050+ type line_num = int
5151+ (** The type for one-based line numbers. *)
5252+5353+ val line_num_none : line_num
5454+ (** [line_num_none] is [-1]. A line number to use when there is none. *)
5555+5656+ type line_pos = line_num * byte_pos
5757+ (** The type for line positions. A one-based line number and the
5858+ byte position of the first byte of the line. *)
5959+6060+ val line_pos_first : line_pos
6161+ (** [line_pos_first] is [(1, 0)]. *)
6262+6363+ val line_pos_none : line_pos
6464+ (** [line_pos_none] is [(line_num_none, byte_pos_none)]. *)
6565+6666+ (** {1:tlocs Text locations} *)
6767+6868+ type t
6969+ (** The type for text locations. A text location identifies a text span
7070+ in a file by an inclusive byte position range and its line positions. *)
7171+7272+ val none : t
7373+ (** [none] is a text location with no information. *)
7474+7575+ val make :
7676+ file:fpath ->
7777+ first_byte:byte_pos -> last_byte:byte_pos ->
7878+ first_line:line_pos -> last_line:line_pos -> t
7979+ (** [make ~file ~first_byte ~last_byte ~first_line ~last_line] is a text
8080+ location with the given data. *)
8181+8282+ val file : t -> fpath
8383+ (** [file l] is the file of [l]. *)
8484+8585+ val set_file : t -> fpath -> t
8686+ (** [set_file l f] is [l] with [file] set to [f]. *)
8787+8888+ val first_byte : t -> byte_pos
8989+ (** [first_byte l] is the first byte position of [l]. *)
9090+9191+ val last_byte : t -> byte_pos
9292+ (** [last_byte l] is the last byte position of [l]. *)
9393+9494+ val first_line : t -> line_pos
9595+ (** [first_line l] is the first line position of [l]. *)
9696+9797+ val last_line : t -> line_pos
9898+ (** [last_line l] is the last line position of [l]. *)
9999+100100+ val is_none : t -> bool
101101+ (** [is_none l] is [true] iff [first_byte l < 0]. *)
102102+103103+ val is_empty : t -> bool
104104+ (** [is_empty l] is [true] iff [first_byte l > last_byte l]. *)
105105+106106+ val equal : t -> t -> bool
107107+ (** [equal l0 l1] tests [l0] and [l1] for equality. *)
108108+109109+ val compare : t -> t -> int
110110+ (** [compare l0 l1] is a total order on locations. *)
111111+112112+ val set_first : t -> first_byte:byte_pos -> first_line:line_pos -> t
113113+ (** [set_first l ~first_byte ~first_line] updates the first position of [l]. *)
114114+115115+ val set_last : t -> last_byte:byte_pos -> last_line:line_pos -> t
116116+ (** [set_last l ~last_byte ~last_line] updates the last position of [l]. *)
117117+118118+ val to_first : t -> t
119119+ (** [to_first l] has the start of [l] as its start and end. *)
120120+121121+ val to_last : t -> t
122122+ (** [to_last l] has the end of [l] as its start and end. *)
123123+124124+ val before : t -> t
125125+ (** [before l] is the empty location just before [l]. *)
126126+127127+ val after : t -> t
128128+ (** [after l] is the empty location just after [l]. *)
129129+130130+ val span : t -> t -> t
131131+ (** [span l0 l1] is the span from the smallest position of [l0] and [l1]
132132+ to the largest position of [l0] and [l1]. *)
133133+134134+ val reloc : first:t -> last:t -> t
135135+ (** [reloc ~first ~last] is a location that spans from [first] to [last]. *)
136136+137137+ (** {1:fmt Formatting} *)
138138+139139+ val pp_ocaml : t fmt
140140+ (** [pp_ocaml] formats location using OCaml syntax. *)
141141+142142+ val pp_gnu : t fmt
143143+ (** [pp_gnu] formats location using GNU syntax. *)
144144+145145+ val pp : t fmt
146146+ (** [pp] is {!pp_ocaml}. *)
147147+148148+ val pp_dump : t fmt
149149+ (** [pp_dump] formats the location for debugging. *)
150150+end
151151+152152+(** {1:meta Metadata} *)
153153+154154+(** INI element metadata.
155155+156156+ Metadata holds text location and layout information (whitespace and
157157+ comments) for INI elements. This enables layout-preserving round-trips. *)
158158+module Meta : sig
159159+160160+ type t
161161+ (** The type for element metadata. *)
162162+163163+ val none : t
164164+ (** [none] is metadata with no information. *)
165165+166166+ val make : ?ws_before:string -> ?ws_after:string -> ?comment:string ->
167167+ Textloc.t -> t
168168+ (** [make ?ws_before ?ws_after ?comment textloc] creates metadata. *)
169169+170170+ val is_none : t -> bool
171171+ (** [is_none m] is [true] iff [m] has no text location. *)
172172+173173+ val textloc : t -> Textloc.t
174174+ (** [textloc m] is the text location of [m]. *)
175175+176176+ val ws_before : t -> string
177177+ (** [ws_before m] is whitespace before the element. *)
178178+179179+ val ws_after : t -> string
180180+ (** [ws_after m] is whitespace after the element. *)
181181+182182+ val comment : t -> string option
183183+ (** [comment m] is the associated comment, if any. *)
184184+185185+ val with_textloc : t -> Textloc.t -> t
186186+ (** [with_textloc m loc] is [m] with text location [loc]. *)
187187+188188+ val with_ws_before : t -> string -> t
189189+ (** [with_ws_before m ws] is [m] with [ws_before] set to [ws]. *)
190190+191191+ val with_ws_after : t -> string -> t
192192+ (** [with_ws_after m ws] is [m] with [ws_after] set to [ws]. *)
193193+194194+ val with_comment : t -> string option -> t
195195+ (** [with_comment m c] is [m] with [comment] set to [c]. *)
196196+197197+ val clear_ws : t -> t
198198+ (** [clear_ws m] clears whitespace from [m]. *)
199199+200200+ val clear_textloc : t -> t
201201+ (** [clear_textloc m] sets textloc to {!Textloc.none}. *)
202202+203203+ val copy_ws : t -> dst:t -> t
204204+ (** [copy_ws src ~dst] copies whitespace from [src] to [dst]. *)
205205+end
206206+207207+type 'a node = 'a * Meta.t
208208+(** The type for values with metadata. *)
209209+210210+(** {1:paths Paths} *)
211211+212212+(** INI paths.
213213+214214+ Paths identify locations within an INI document, such as
215215+ [\[section\]/option]. *)
216216+module Path : sig
217217+218218+ (** {1:indices Path indices} *)
219219+220220+ type index =
221221+ | Section of string node (** A section name. *)
222222+ | Option of string node (** An option name. *)
223223+ (** The type for path indices. *)
224224+225225+ val pp_index : index fmt
226226+ (** [pp_index] formats an index. *)
227227+228228+ (** {1:paths Paths} *)
229229+230230+ type t
231231+ (** The type for paths. *)
232232+233233+ val root : t
234234+ (** [root] is the empty path. *)
235235+236236+ val is_root : t -> bool
237237+ (** [is_root p] is [true] iff [p] is {!root}. *)
238238+239239+ val section : ?meta:Meta.t -> string -> t -> t
240240+ (** [section ?meta name p] appends a section index to [p]. *)
241241+242242+ val option : ?meta:Meta.t -> string -> t -> t
243243+ (** [option ?meta name p] appends an option index to [p]. *)
244244+245245+ val rev_indices : t -> index list
246246+ (** [rev_indices p] is the list of indices in reverse order. *)
247247+248248+ val pp : t fmt
249249+ (** [pp] formats a path. *)
250250+end
251251+252252+(** {1:errors Errors} *)
253253+254254+(** Error handling. *)
255255+module Error : sig
256256+257257+ (** {1:kinds Error kinds} *)
258258+259259+ type kind =
260260+ | Parse of string
261261+ | Codec of string
262262+ | Missing_section of string
263263+ | Missing_option of { section : string; option : string }
264264+ | Duplicate_section of string
265265+ | Duplicate_option of { section : string; option : string }
266266+ | Type_mismatch of { expected : string; got : string }
267267+ | Interpolation of { option : string; reason : string }
268268+ | Unknown_option of string
269269+ | Unknown_section of string
270270+ (** The type for error kinds. *)
271271+272272+ (** {1:errors Errors} *)
273273+274274+ type t
275275+ (** The type for errors. *)
276276+277277+ val make : ?meta:Meta.t -> ?path:Path.t -> kind -> t
278278+ (** [make ?meta ?path kind] creates an error. *)
279279+280280+ val kind : t -> kind
281281+ (** [kind e] is the error kind. *)
282282+283283+ val meta : t -> Meta.t
284284+ (** [meta e] is the error metadata. *)
285285+286286+ val path : t -> Path.t
287287+ (** [path e] is the error path. *)
288288+289289+ exception Error of t
290290+ (** Exception for errors. *)
291291+292292+ val raise : ?meta:Meta.t -> ?path:Path.t -> kind -> 'a
293293+ (** [raise ?meta ?path kind] raises {!Error}. *)
294294+295295+ val kind_to_string : kind -> string
296296+ (** [kind_to_string k] is a string representation of [k]. *)
297297+298298+ val to_string : t -> string
299299+ (** [to_string e] formats the error as a string. *)
300300+301301+ val pp : t fmt
302302+ (** [pp] formats an error. *)
303303+end
304304+305305+(** {1:repr Internal Representations}
306306+307307+ These types are exposed for use by {!Init_bytesrw}. *)
308308+module Repr : sig
309309+310310+ (** {1:values INI Values} *)
311311+312312+ type ini_value = {
313313+ raw : string;
314314+ interpolated : string;
315315+ meta : Meta.t;
316316+ }
317317+ (** The type for decoded INI values. [raw] is the value before
318318+ interpolation, [interpolated] after. *)
319319+320320+ (** {1:sections INI Sections} *)
321321+322322+ type ini_section = {
323323+ name : string node;
324324+ options : (string node * ini_value) list;
325325+ meta : Meta.t;
326326+ }
327327+ (** The type for decoded INI sections. *)
328328+329329+ (** {1:docs INI Documents} *)
330330+331331+ type ini_doc = {
332332+ defaults : (string node * ini_value) list;
333333+ sections : ini_section list;
334334+ meta : Meta.t;
335335+ }
336336+ (** The type for decoded INI documents. *)
337337+338338+ (** {1:codec_state Codec State} *)
339339+340340+ type 'a codec_result = ('a, Error.t) result
341341+ (** The type for codec results. *)
342342+343343+ type 'a section_state = {
344344+ decode : ini_section -> 'a codec_result;
345345+ encode : 'a -> ini_section;
346346+ known_options : string list;
347347+ unknown_handler : [ `Skip | `Error | `Keep ];
348348+ }
349349+ (** Section codec state. *)
350350+351351+ type 'a document_state = {
352352+ decode : ini_doc -> 'a codec_result;
353353+ encode : 'a -> ini_doc;
354354+ known_sections : string list;
355355+ unknown_handler : [ `Skip | `Error ];
356356+ }
357357+ (** Document codec state. *)
358358+end
359359+360360+(** {1:codecs Codecs} *)
361361+362362+type 'a t
363363+(** The type for INI codecs. A value of type ['a t] describes how to
364364+ decode INI data to type ['a] and encode ['a] to INI data. *)
365365+366366+val kind : 'a t -> string
367367+(** [kind c] is a description of the kind of values [c] represents. *)
368368+369369+val doc : 'a t -> string
370370+(** [doc c] is the documentation for [c]. *)
371371+372372+val with_doc : ?kind:string -> ?doc:string -> 'a t -> 'a t
373373+(** [with_doc ?kind ?doc c] is [c] with updated kind and doc. *)
374374+375375+val section_state : 'a t -> 'a Repr.section_state option
376376+(** [section_state c] returns the section decode/encode state, if [c]
377377+ was created with {!Section.finish}. *)
378378+379379+val document_state : 'a t -> 'a Repr.document_state option
380380+(** [document_state c] returns the document decode/encode state, if [c]
381381+ was created with {!Document.finish}. *)
382382+383383+(** {2:base_codecs Base Codecs} *)
384384+385385+val string : string t
386386+(** [string] is a codec for string values. *)
387387+388388+val int : int t
389389+(** [int] is a codec for integer values. *)
390390+391391+val int32 : int32 t
392392+(** [int32] is a codec for 32-bit integer values. *)
393393+394394+val int64 : int64 t
395395+(** [int64] is a codec for 64-bit integer values. *)
396396+397397+val float : float t
398398+(** [float] is a codec for floating-point values. *)
399399+400400+val bool : bool t
401401+(** [bool] is a codec for Python-compatible booleans.
402402+ Accepts (case-insensitive): [1/yes/true/on] for true,
403403+ [0/no/false/off] for false. *)
404404+405405+val bool_01 : bool t
406406+(** [bool_01] is a strict codec for ["0"]/["1"] booleans. *)
407407+408408+val bool_yesno : bool t
409409+(** [bool_yesno] is a codec for ["yes"]/["no"] booleans. *)
410410+411411+val bool_truefalse : bool t
412412+(** [bool_truefalse] is a codec for ["true"]/["false"] booleans. *)
413413+414414+val bool_onoff : bool t
415415+(** [bool_onoff] is a codec for ["on"]/["off"] booleans. *)
416416+417417+(** {2:combinators Combinators} *)
418418+419419+val map : ?kind:string -> ?doc:string ->
420420+ dec:('a -> 'b) -> enc:('b -> 'a) -> 'a t -> 'b t
421421+(** [map ~dec ~enc c] transforms [c] using [dec] for decoding
422422+ and [enc] for encoding. *)
423423+424424+val enum : ?cmp:('a -> 'a -> int) -> ?kind:string -> ?doc:string ->
425425+ (string * 'a) list -> 'a t
426426+(** [enum assoc] is a codec for enumerated values. String matching
427427+ is case-insensitive. *)
428428+429429+val option : ?kind:string -> ?doc:string -> 'a t -> 'a option t
430430+(** [option c] is a codec for optional values. Empty strings decode
431431+ to [None]. *)
432432+433433+val default : 'a -> 'a t -> 'a t
434434+(** [default v c] uses [v] when decoding fails. *)
435435+436436+val list : ?sep:char -> 'a t -> 'a list t
437437+(** [list ?sep c] is a codec for lists of values separated by [sep]
438438+ (default: [',']). *)
439439+440440+(** {1:sections Section Codecs}
441441+442442+ Build codecs for INI sections using an applicative style. *)
443443+module Section : sig
444444+445445+ type 'a codec = 'a t
446446+ (** Alias for codec type. *)
447447+448448+ type ('o, 'dec) map
449449+ (** The type for section maps. ['o] is the OCaml type being built,
450450+ ['dec] is the remaining constructor arguments. *)
451451+452452+ val obj : ?kind:string -> ?doc:string -> 'dec -> ('o, 'dec) map
453453+ (** [obj f] starts building a section codec with constructor [f]. *)
454454+455455+ val mem : ?doc:string -> ?dec_absent:'a -> ?enc:('o -> 'a) ->
456456+ ?enc_omit:('a -> bool) ->
457457+ string -> 'a codec -> ('o, 'a -> 'dec) map -> ('o, 'dec) map
458458+ (** [mem name c m] adds an option [name] decoded by [c] to map [m].
459459+ @param dec_absent Default value if option is absent.
460460+ @param enc Encoder function to extract value from ['o].
461461+ @param enc_omit Predicate; if true, omit option during encoding. *)
462462+463463+ val opt_mem : ?doc:string -> ?enc:('o -> 'a option) ->
464464+ string -> 'a codec -> ('o, 'a option -> 'dec) map -> ('o, 'dec) map
465465+ (** [opt_mem name c m] adds an optional option (decodes to [None] if absent). *)
466466+467467+ val skip_unknown : ('o, 'dec) map -> ('o, 'dec) map
468468+ (** [skip_unknown m] ignores unknown options (default). *)
469469+470470+ val error_unknown : ('o, 'dec) map -> ('o, 'dec) map
471471+ (** [error_unknown m] raises an error on unknown options. *)
472472+473473+ val keep_unknown : ?enc:('o -> (string * string) list) ->
474474+ ('o, (string * string) list -> 'dec) map -> ('o, 'dec) map
475475+ (** [keep_unknown m] captures unknown options as a list of (name, value) pairs. *)
476476+477477+ val finish : ('o, 'o) map -> 'o codec
478478+ (** [finish m] completes the section codec. *)
479479+end
480480+481481+(** {1:documents Document Codecs}
482482+483483+ Build codecs for complete INI documents. *)
484484+module Document : sig
485485+486486+ type 'a codec = 'a t
487487+ (** Alias for codec type. *)
488488+489489+ type ('o, 'dec) map
490490+ (** The type for document maps. *)
491491+492492+ val obj : ?kind:string -> ?doc:string -> 'dec -> ('o, 'dec) map
493493+ (** [obj f] starts building a document codec with constructor [f]. *)
494494+495495+ val section : ?doc:string -> ?enc:('o -> 'a) ->
496496+ string -> 'a Section.codec -> ('o, 'a -> 'dec) map -> ('o, 'dec) map
497497+ (** [section name c m] adds a required section [name] to map [m]. *)
498498+499499+ val opt_section : ?doc:string -> ?enc:('o -> 'a option) ->
500500+ string -> 'a Section.codec -> ('o, 'a option -> 'dec) map -> ('o, 'dec) map
501501+ (** [opt_section name c m] adds an optional section [name] to map [m]. *)
502502+503503+ val defaults : ?doc:string -> ?enc:('o -> 'a) ->
504504+ 'a Section.codec -> ('o, 'a -> 'dec) map -> ('o, 'dec) map
505505+ (** [defaults c m] decodes the DEFAULT section using [c]. *)
506506+507507+ val opt_defaults : ?doc:string -> ?enc:('o -> 'a option) ->
508508+ 'a Section.codec -> ('o, 'a option -> 'dec) map -> ('o, 'dec) map
509509+ (** [opt_defaults c m] optionally decodes the DEFAULT section. *)
510510+511511+ val skip_unknown : ('o, 'dec) map -> ('o, 'dec) map
512512+ (** [skip_unknown m] ignores unknown sections (default). *)
513513+514514+ val error_unknown : ('o, 'dec) map -> ('o, 'dec) map
515515+ (** [error_unknown m] raises an error on unknown sections. *)
516516+517517+ val finish : ('o, 'o) map -> 'o codec
518518+ (** [finish m] completes the document codec. *)
519519+end
+3
test/data/cfgparser.1
···11+# Also used by idlelib.test_idle.test_config.
22+[Foo Bar]
33+foo=newbar
+537
test/data/cfgparser.2
···11+# This is the main Samba configuration file. You should read the
22+# smb.conf(5) manual page in order to understand the options listed
33+# here. Samba has a huge number of configurable options (perhaps too
44+# many!) most of which are not shown in this example
55+#
66+# Any line which starts with a ; (semi-colon) or a # (hash)
77+# is a comment and is ignored. In this example we will use a #
88+# for commentry and a ; for parts of the config file that you
99+# may wish to enable
1010+#
1111+# NOTE: Whenever you modify this file you should run the command #"testparm" # to check that you have not made any basic syntactic #errors.
1212+#
1313+#======================= Global Settings =====================================
1414+[global]
1515+1616+# 1. Server Naming Options:
1717+# workgroup = NT-Domain-Name or Workgroup-Name
1818+1919+ workgroup = MDKGROUP
2020+2121+# netbios name is the name you will see in "Network Neighbourhood",
2222+# but defaults to your hostname
2323+2424+; netbios name = <name_of_this_server>
2525+2626+# server string is the equivalent of the NT Description field
2727+2828+ server string = Samba Server %v
2929+3030+# Message command is run by samba when a "popup" message is sent to it.
3131+# The example below is for use with LinPopUp:
3232+; message command = /usr/bin/linpopup "%f" "%m" %s; rm %s
3333+3434+# 2. Printing Options:
3535+# CHANGES TO ENABLE PRINTING ON ALL CUPS PRINTERS IN THE NETWORK
3636+# (as cups is now used in linux-mandrake 7.2 by default)
3737+# if you want to automatically load your printer list rather
3838+# than setting them up individually then you'll need this
3939+4040+ printcap name = lpstat
4141+ load printers = yes
4242+4343+# It should not be necessary to spell out the print system type unless
4444+# yours is non-standard. Currently supported print systems include:
4545+# bsd, sysv, plp, lprng, aix, hpux, qnx, cups
4646+4747+ printing = cups
4848+4949+# Samba 2.2 supports the Windows NT-style point-and-print feature. To
5050+# use this, you need to be able to upload print drivers to the samba
5151+# server. The printer admins (or root) may install drivers onto samba.
5252+# Note that this feature uses the print$ share, so you will need to
5353+# enable it below.
5454+# This parameter works like domain admin group:
5555+# printer admin = @<group> <user>
5656+; printer admin = @adm
5757+# This should work well for winbind:
5858+; printer admin = @"Domain Admins"
5959+6060+# 3. Logging Options:
6161+# this tells Samba to use a separate log file for each machine
6262+# that connects
6363+6464+ log file = /var/log/samba/log.%m
6565+6666+# Put a capping on the size of the log files (in Kb).
6767+ max log size = 50
6868+6969+# Set the log (verbosity) level (0 <= log level <= 10)
7070+; log level = 3
7171+7272+# 4. Security and Domain Membership Options:
7373+# This option is important for security. It allows you to restrict
7474+# connections to machines which are on your local network. The
7575+# following example restricts access to two C class networks and
7676+# the "loopback" interface. For more examples of the syntax see
7777+# the smb.conf man page. Do not enable this if (tcp/ip) name resolution #does
7878+# not work for all the hosts in your network.
7979+; hosts allow = 192.168.1. 192.168.2. 127.
8080+8181+ hosts allow = 127. //note this is only my private IP address
8282+8383+# Uncomment this if you want a guest account, you must add this to
8484+# /etc/passwd
8585+# otherwise the user "nobody" is used
8686+; guest account = pcguest
8787+8888+# Security mode. Most people will want user level security. See
8989+# security_level.txt for details.
9090+9191+ security = user
9292+9393+# Use password server option only with security = server or security = # domain
9494+# When using security = domain, you should use password server = *
9595+; password server =
9696+; password server = *
9797+9898+# Password Level allows matching of _n_ characters of the password for
9999+# all combinations of upper and lower case.
100100+101101+ password level = 8
102102+103103+; username level = 8
104104+105105+# You may wish to use password encryption. Please read
106106+# ENCRYPTION.txt, Win95.txt and WinNT.txt in the Samba documentation.
107107+# Do not enable this option unless you have read those documents
108108+# Encrypted passwords are required for any use of samba in a Windows NT #domain
109109+# The smbpasswd file is only required by a server doing authentication, #thus members of a domain do not need one.
110110+111111+ encrypt passwords = yes
112112+ smb passwd file = /etc/samba/smbpasswd
113113+114114+# The following are needed to allow password changing from Windows to
115115+# also update the Linux system password.
116116+# NOTE: Use these with 'encrypt passwords' and 'smb passwd file' above.
117117+# NOTE2: You do NOT need these to allow workstations to change only
118118+# the encrypted SMB passwords. They allow the Unix password
119119+# to be kept in sync with the SMB password.
120120+; unix password sync = Yes
121121+# You either need to setup a passwd program and passwd chat, or
122122+# enable pam password change
123123+; pam password change = yes
124124+; passwd program = /usr/bin/passwd %u
125125+; passwd chat = *New*UNIX*password* %n\n *ReType*new*UNIX*password*
126126+# %n\n
127127+;*passwd:*all*authentication*tokens*updated*successfully*
128128+129129+# Unix users can map to different SMB User names
130130+; username map = /etc/samba/smbusers
131131+132132+# Using the following line enables you to customize your configuration
133133+# on a per machine basis. The %m gets replaced with the netbios name
134134+# of the machine that is connecting
135135+; include = /etc/samba/smb.conf.%m
136136+137137+# Options for using winbind. Winbind allows you to do all account and
138138+# authentication from a Windows or samba domain controller, creating
139139+# accounts on the fly, and maintaining a mapping of Windows RIDs to
140140+# unix uid's
141141+# and gid's. winbind uid and winbind gid are the only required
142142+# parameters.
143143+#
144144+# winbind uid is the range of uid's winbind can use when mapping RIDs #to uid's
145145+; winbind uid = 10000-20000
146146+#
147147+# winbind gid is the range of uid's winbind can use when mapping RIDs
148148+# to gid's
149149+; winbind gid = 10000-20000
150150+#
151151+# winbind separator is the character a user must use between their
152152+# domain name and username, defaults to "\"
153153+; winbind separator = +
154154+#
155155+# winbind use default domain allows you to have winbind return
156156+# usernames in the form user instead of DOMAIN+user for the domain
157157+# listed in the workgroup parameter.
158158+; winbind use default domain = yes
159159+#
160160+# template homedir determines the home directory for winbind users,
161161+# with %D expanding to their domain name and %U expanding to their
162162+# username:
163163+; template homedir = /home/%D/%U
164164+165165+# When using winbind, you may want to have samba create home
166166+# directories on the fly for authenticated users. Ensure that
167167+# /etc/pam.d/samba is using 'service=system-auth-winbind' in pam_stack
168168+# modules, and then enable obedience of pam restrictions below:
169169+; obey pam restrictions = yes
170170+171171+#
172172+# template shell determines the shell users authenticated by winbind #get
173173+; template shell = /bin/bash
174174+175175+# 5. Browser Control and Networking Options:
176176+# Most people will find that this option gives better performance.
177177+# See speed.txt and the manual pages for details
178178+179179+ socket options = TCP_NODELAY SO_RCVBUF=8192 SO_SNDBUF=8192
180180+181181+# Configure Samba to use multiple interfaces
182182+# If you have multiple network interfaces then you must list them
183183+# here. See the man page for details.
184184+; interfaces = 192.168.12.2/24 192.168.13.2/24
185185+186186+# Configure remote browse list synchronisation here
187187+# request announcement to, or browse list sync from:
188188+# a specific host or from / to a whole subnet (see below)
189189+; remote browse sync = 192.168.3.25 192.168.5.255
190190+# Cause this host to announce itself to local subnets here
191191+; remote announce = 192.168.1.255 192.168.2.44
192192+193193+# set local master to no if you don't want Samba to become a master
194194+# browser on your network. Otherwise the normal election rules apply
195195+; local master = no
196196+197197+# OS Level determines the precedence of this server in master browser
198198+# elections. The default value should be reasonable
199199+; os level = 33
200200+201201+# Domain Master specifies Samba to be the Domain Master Browser. This
202202+# allows Samba to collate browse lists between subnets. Don't use this
203203+# if you already have a Windows NT domain controller doing this job
204204+; domain master = yes
205205+206206+# Preferred Master causes Samba to force a local browser election on
207207+# startup and gives it a slightly higher chance of winning the election
208208+; preferred master = yes
209209+210210+# 6. Domain Control Options:
211211+# Enable this if you want Samba to be a domain logon server for
212212+# Windows95 workstations or Primary Domain Controller for WinNT and
213213+# Win2k
214214+215215+; domain logons = yes
216216+217217+218218+# if you enable domain logons then you may want a per-machine or
219219+# per user logon script
220220+# run a specific logon batch file per workstation (machine)
221221+; logon script = %m.bat
222222+# run a specific logon batch file per username
223223+; logon script = %U.bat
224224+225225+# Where to store roaming profiles for WinNT and Win2k
226226+# %L substitutes for this servers netbios name, %U is username
227227+# You must uncomment the [Profiles] share below
228228+; logon path = \\%L\Profiles\%U
229229+230230+# Where to store roaming profiles for Win9x. Be careful with this as it
231231+# also impacts where Win2k finds it's /HOME share
232232+; logon home = \\%L\%U\.profile
233233+234234+# The add user script is used by a domain member to add local user
235235+# accounts that have been authenticated by the domain controller, or by
236236+# the domain controller to add local machine accounts when adding
237237+# machines to the domain.
238238+# The script must work from the command line when replacing the macros,
239239+# or the operation will fail. Check that groups exist if forcing a
240240+# group.
241241+# Script for domain controller for adding machines:
242242+; add user script = /usr/sbin/useradd -d /dev/null -g machines –c
243243+# 'Machine Account' -s /bin/false -M %u
244244+# Script for domain controller with LDAP backend for adding machines
245245+#(please
246246+# configure in /etc/samba/smbldap_conf.pm first):
247247+; add user script = /usr/share/samba/scripts/smbldap-useradd.pl -w –d
248248+# /dev/null -g machines -c 'Machine Account' -s /bin/false %u
249249+# Script for domain member for adding local accounts for authenticated
250250+# users:
251251+; add user script = /usr/sbin/useradd -s /bin/false %u
252252+253253+# Domain groups:
254254+# domain admin group is a list of unix users or groups who are made
255255+# members
256256+# of the Domain Admin group
257257+; domain admin group = root @wheel
258258+#
259259+# domain guest groups is a list of unix users or groups who are made
260260+# members
261261+# of the Domain Guests group
262262+; domain guest group = nobody @guest
263263+264264+# LDAP configuration for Domain Controlling:
265265+# The account (dn) that samba uses to access the LDAP server
266266+# This account needs to have write access to the LDAP tree
267267+# You will need to give samba the password for this dn, by
268268+# running 'smbpasswd -w mypassword'
269269+; ldap admin dn = cn=root,dc=mydomain,dc=com
270270+; ldap ssl = start_tls
271271+# start_tls should run on 389, but samba defaults incorrectly to 636
272272+; ldap port = 389
273273+; ldap suffix = dc=mydomain,dc=com
274274+; ldap server = ldap.mydomain.com
275275+276276+277277+# 7. Name Resolution Options:
278278+# All NetBIOS names must be resolved to IP Addresses
279279+# 'Name Resolve Order' allows the named resolution mechanism to be
280280+# specified the default order is "host lmhosts wins bcast". "host"
281281+# means use the unix system gethostbyname() function call that will use
282282+# either /etc/hosts OR DNS or NIS depending on the settings of
283283+# /etc/host.config, /etc/nsswitch.conf
284284+# and the /etc/resolv.conf file. "host" therefore is system
285285+# configuration dependent. This parameter is most often of use to
286286+# prevent DNS lookups
287287+# in order to resolve NetBIOS names to IP Addresses. Use with care!
288288+# The example below excludes use of name resolution for machines that
289289+# are NOT on the local network segment - OR - are not deliberately to
290290+# be known via lmhosts or via WINS.
291291+; name resolve order = wins lmhosts bcast
292292+293293+# Windows Internet Name Serving Support Section:
294294+# WINS Support - Tells the NMBD component of Samba to enable it's WINS
295295+# Server
296296+; wins support = yes
297297+298298+# WINS Server - Tells the NMBD components of Samba to be a WINS Client
299299+# Note: Samba can be either a WINS Server, or a WINS Client, but
300300+# NOT both
301301+; wins server = w.x.y.z
302302+303303+# WINS Proxy - Tells Samba to answer name resolution queries on
304304+# behalf of a non WINS capable client, for this to work there must be
305305+# at least one WINS Server on the network. The default is NO.
306306+; wins proxy = yes
307307+308308+# DNS Proxy - tells Samba whether or not to try to resolve NetBIOS
309309+# names via DNS nslookups. The built-in default for versions 1.9.17 is
310310+# yes, this has been changed in version 1.9.18 to no.
311311+312312+ dns proxy = no
313313+314314+# 8. File Naming Options:
315315+# Case Preservation can be handy - system default is _no_
316316+# NOTE: These can be set on a per share basis
317317+; preserve case = no
318318+; short preserve case = no
319319+# Default case is normally upper case for all DOS files
320320+; default case = lower
321321+# Be very careful with case sensitivity - it can break things!
322322+; case sensitive = no
323323+324324+# Enabling internationalization:
325325+# you can match a Windows code page with a UNIX character set.
326326+# Windows: 437 (US), 737 (GREEK), 850 (Latin1 - Western European),
327327+# 852 (Eastern Eu.), 861 (Icelandic), 932 (Cyrillic - Russian),
328328+# 936 (Japanese - Shift-JIS), 936 (Simpl. Chinese), 949 (Korean
329329+# Hangul),
330330+# 950 (Trad. Chin.).
331331+# UNIX: ISO8859-1 (Western European), ISO8859-2 (Eastern Eu.),
332332+# ISO8859-5 (Russian Cyrillic), KOI8-R (Alt-Russ. Cyril.)
333333+# This is an example for french users:
334334+; client code page = 850
335335+; character set = ISO8859-1
336336+337337+#============================ Share Definitions ==============================
338338+339339+[homes]
340340+ comment = Home Directories
341341+ browseable = no
342342+ writable = yes
343343+344344+# You can enable VFS recycle bin on a per share basis:
345345+# Uncomment the next 2 lines (make sure you create a
346346+# .recycle folder in the base of the share and ensure
347347+# all users will have write access to it. See
348348+# examples/VFS/recycle/REAME in samba-doc for details
349349+; vfs object = /usr/lib/samba/vfs/recycle.so
350350+; vfs options= /etc/samba/recycle.conf
351351+352352+# Un-comment the following and create the netlogon directory for Domain
353353+# Logons
354354+; [netlogon]
355355+; comment = Network Logon Service
356356+; path = /var/lib/samba/netlogon
357357+; guest ok = yes
358358+; writable = no
359359+360360+#Uncomment the following 2 lines if you would like your login scripts
361361+# to be created dynamically by ntlogon (check that you have it in the
362362+# correct location (the default of the ntlogon rpm available in
363363+# contribs)
364364+365365+;root preexec = /usr/bin/ntlogon -u %U -g %G -o %a -d /var/lib/samba/netlogon
366366+;root postexec = rm -f /var/lib/samba/netlogon/%U.bat
367367+368368+# Un-comment the following to provide a specific roving profile share
369369+# the default is to use the user's home directory
370370+;[Profiles]
371371+; path = /var/lib/samba/profiles
372372+; browseable = no
373373+; guest ok = yes
374374+375375+376376+# NOTE: If you have a CUPS print system there is no need to
377377+# specifically define each individual printer.
378378+# You must configure the samba printers with the appropriate Windows
379379+# drivers on your Windows clients. On the Samba server no filtering is
380380+# done. If you wish that the server provides the driver and the clients
381381+# send PostScript ("Generic PostScript Printer" under Windows), you
382382+# have to swap the 'print command' line below with the commented one.
383383+384384+[printers]
385385+ comment = All Printers
386386+ path = /var/spool/samba
387387+ browseable = no
388388+# to allow user 'guest account' to print.
389389+ guest ok = yes
390390+ writable = no
391391+ printable = yes
392392+ create mode = 0700
393393+394394+# =====================================
395395+# print command: see above for details.
396396+# =====================================
397397+398398+ print command = lpr-cups -P %p -o raw %s -r
399399+# using client side printer drivers.
400400+; print command = lpr-cups -P %p %s
401401+# using cups own drivers (use generic PostScript on clients).
402402+# The following two commands are the samba defaults for printing=cups
403403+# change them only if you need different options:
404404+; lpq command = lpq -P %p
405405+; lprm command = cancel %p-%j
406406+407407+# This share is used for Windows NT-style point-and-print support.
408408+# To be able to install drivers, you need to be either root, or listed
409409+# in the printer admin parameter above. Note that you also need write
410410+# access to the directory and share definition to be able to upload the
411411+# drivers.
412412+# For more information on this, please see the Printing Support Section
413413+# of /usr/share/doc/samba-/docs/Samba-HOWTO-Collection.pdf
414414+415415+[print$]
416416+ path = /var/lib/samba/printers
417417+ browseable = yes
418418+ read only = yes
419419+ write list = @adm root
420420+421421+# A useful application of samba is to make a PDF-generation service
422422+# To streamline this, install windows postscript drivers (preferably
423423+# colour)on the samba server, so that clients can automatically install
424424+# them.
425425+426426+[pdf-generator]
427427+ path = /var/tmp
428428+ guest ok = No
429429+ printable = Yes
430430+ comment = PDF Generator (only valid users)
431431+ #print command = /usr/share/samba/scripts/print-pdf file path win_path recipient IP &
432432+ print command = /usr/share/samba/scripts/print-pdf %s ~%u \\\\\\\\%L\\\\%u %m %I &
433433+434434+# This one is useful for people to share files
435435+[tmp]
436436+ comment = Temporary file space
437437+ path = /tmp
438438+ read only = no
439439+ public = yes
440440+ echo command = cat %s; rm %s
441441+442442+# A publicly accessible directory, but read only, except for people in
443443+# the "staff" group
444444+445445+446446+447447+448448+;[public]
449449+; comment = Public Stuff
450450+; path = /home/samba/public
451451+; public = yes
452452+; writable = no
453453+; write list = @staff
454454+# Audited directory through experimental VFS audit.so module:
455455+# Uncomment next line.
456456+; vfs object = /usr/lib/samba/vfs/audit.so
457457+458458+# Other examples.
459459+#
460460+# A private printer, usable only by Fred. Spool data will be placed in
461461+# Fred's
462462+# home directory. Note that fred must have write access to the spool
463463+# directory,
464464+# wherever it is.
465465+;[fredsprn]
466466+; comment = Fred's Printer
467467+; valid users = fred
468468+; path = /homes/fred
469469+; printer = freds_printer
470470+; public = no
471471+; writable = no
472472+; printable = yes
473473+474474+475475+-----------------------------------------------------------
476476+# A private directory, usable only by Fred. Note that Fred requires
477477+# write access to the directory.
478478+479479+;[fredsdir]
480480+481481+ [Agustin]
482482+; comment = Fred's Service
483483+ comment = Agustin Private Files
484484+; path = /usr/somewhere/private
485485+ path = /home/agustin/Documents
486486+; valid users = fred
487487+ valid users = agustin
488488+; public = no
489489+; writable = yes
490490+ writable = yes
491491+; printable = no
492492+493493+494494+-----------------------------------------------------------
495495+496496+# a service which has a different directory for each machine that
497497+# connects this allows you to tailor configurations to incoming
498498+# machines. You could also use the %u option to tailor it by user name.
499499+# The %m gets replaced with the machine name that is connecting.
500500+;[pchome]
501501+; comment = PC Directories
502502+; path = /usr/pc/%m
503503+; public = no
504504+; writable = yes
505505+506506+507507+-----------------------------------------------------------
508508+# A publicly accessible directory, read/write to all users. Note that
509509+# all files created in the directory by users will be owned by the
510510+# default user, so any user with access can delete any other user's
511511+# files. Obviously this directory must be writable by the default user.
512512+# Another user could of course be specified, in which case all files
513513+# would be owned by that user instead.
514514+515515+;[public]
516516+; path = /usr/somewhere/else/public
517517+; public = yes
518518+; only guest = yes
519519+; writable = yes
520520+; printable = no
521521+522522+-----------------------------------------------------------
523523+524524+# The following two entries demonstrate how to share a directory so
525525+# that two users can place files there that will be owned by the
526526+# specific users. In this setup, the directory should be writable by
527527+# both users and should have the sticky bit set on it to prevent abuse.
528528+# Obviously this could be extended to as many users as required.
529529+530530+;[myshare]
531531+; comment = Mary's and Fred's stuff
532532+; path = /usr/somewhere/shared
533533+; valid users = mary fred
534534+; public = no
535535+; writable = yes
536536+; printable = no
537537+; create mask = 0765
+69
test/data/cfgparser.3
···11+ # INI with as many tricky parts as possible
22+ # Most of them could not be used before 3.2
33+44+ # This will be parsed with the following options
55+ # delimiters = {'='}
66+ # comment_prefixes = {'#'}
77+ # allow_no_value = True
88+99+[DEFAULT]
1010+go = %(interpolate)s
1111+1212+[strange]
1313+ values = that are indented # and end with hash comments
1414+ other = that do continue
1515+ in # and still have
1616+ other # comments mixed
1717+ lines # with the values
1818+1919+2020+2121+2222+2323+[corruption]
2424+ value = that is
2525+2626+2727+ actually still here
2828+2929+3030+ and holds all these weird newlines
3131+3232+3333+ # but not for the lines that are comments
3434+ nor the indentation
3535+3636+ another value = # empty string
3737+ yet another # None!
3838+3939+ [yeah, sections can be indented as well]
4040+ and that does not mean = anything
4141+ are they subsections = False
4242+ if you want subsections = use XML
4343+ lets use some Unicode = 片仮名
4444+4545+ [another one!]
4646+ even if values are indented like this = seriously
4747+yes, this still applies to = section "another one!"
4848+this too = are there people with configurations broken as this?
4949+ beware, this is going to be a continuation
5050+ of the value for
5151+ key "this too"
5252+ even if it has a = character
5353+ this is still the continuation
5454+ your editor probably highlights it wrong
5555+ but that's life
5656+# let's set this value so there is no error
5757+# when getting all items for this section:
5858+interpolate = anything will do
5959+6060+[no values here]
6161+# but there's this `go` in DEFAULT
6262+6363+ [tricky interpolation]
6464+ interpolate = do this
6565+ lets = %(go)s
6666+6767+ [more interpolation]
6868+ interpolate = go shopping
6969+ lets = %(go)s