Source file format_string.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
open Base
open! Ppxlib
open Ast_builder.Default

(* ============================================================ *)
(* Hole_key: identifies a hole in a format string *)
(* ============================================================ *)

module Hole_key = struct
  module T = struct
    type t =
      | Number of int
      | Label of string
    [@@deriving sexp_of, compare ~localize]
  end

  include T
  include Comparator.Make (T)

  let to_string = function
    | Number n -> Int.to_string n
    | Label s -> s
  ;;

  let to_string_hum ~total_count = function
    | Number 1 when total_count = 1 -> "_"
    | Number n -> Int.to_string n
    | Label s -> s
  ;;

  let var_name t = "__arg_" ^ to_string t

  let label = function
    | Number _ -> None
    | Label s -> Some s
  ;;
end

(* ============================================================ *)
(* Part: a parsed piece of a format string *)
(* ============================================================ *)

module Part = struct
  type t =
    | Literal of string
    | Hole of Hole_key.t
end

(* ============================================================ *)
(* Step 1: Parse the format string into parts *)
(* ============================================================ *)

type t =
  { text : string
  ; loc : Location.t
  }

let add_literal string ~start ~until ~acc =
  if start >= until
  then acc
  else `Literal (String.sub string ~pos:start ~len:(until - start)) :: acc
;;

let validate_field_name raw_content ~loc =
  let name = String.strip raw_content in
  if not (String.for_all name ~f:(fun c -> Char.is_alphanum c || Char.equal c '_'))
  then Location.Error.raise (Location.Error.createf ~loc "invalid field name: %S" name);
  name
;;

(* Find the next special sequence starting at [pos]: either [%%] (escape) or [%{]
   (interpolation). Returns [None] if no special sequence is found.

   A lone [%] (not followed by [%] or [{]) is treated as a literal character and does not
   need escaping. Only [%{] has special meaning; [%%] is the escape for a literal [%]
   before [{]. *)
let find_next_special string ~pos =
  let len = String.length string in
  let rec scan i =
    if i + 1 >= len
    then None
    else if Char.equal string.[i] '%'
    then (
      match string.[i + 1] with
      | '%' -> Some (`Escape, i)
      | '{' -> Some (`Interp, i)
      | _ -> scan (i + 1))
    else scan (i + 1)
  in
  scan pos
;;

let parse_interpolation string ~loc ~content_start =
  match String.substr_index string ~pos:content_start ~pattern:"}" with
  | None -> Location.raise_errorf ~loc "unterminated %%{"
  | Some close_pos ->
    let raw_content =
      String.sub string ~pos:content_start ~len:(close_pos - content_start)
    in
    let name = validate_field_name raw_content ~loc in
    close_pos + 1, name
;;

(** Parse the format string into a flat list of literals and raw (unclassified) hole
    names. Holes appear as [`Hole name] where [name] is the raw content between [%{] and
    [}]. *)
let rec parse_raw string ~loc ~pos ~acc =
  let len = String.length string in
  match find_next_special string ~pos with
  | None ->
    let acc = add_literal string ~start:pos ~until:len ~acc in
    List.rev acc
  | Some (`Escape, esc_pos) ->
    let acc = add_literal string ~start:pos ~until:esc_pos ~acc in
    let acc = `Literal "%" :: acc in
    parse_raw string ~loc ~pos:(esc_pos + 2) ~acc
  | Some (`Interp, interp_pos) ->
    let acc = add_literal string ~start:pos ~until:interp_pos ~acc in
    let next_pos, name =
      parse_interpolation string ~loc ~content_start:(interp_pos + 2)
    in
    let acc = `Hole name :: acc in
    parse_raw string ~loc ~pos:next_pos ~acc
;;

(** Classify raw parts into [Part.t]: literal strings pass through, and raw hole names are
    resolved to [Hole_key.t] values ([_] and bare numbers become [Number], everything else
    becomes [Label]). Validates numbering constraints (1-indexed, in range). *)
let classify raw_parts ~loc =
  let classified =
    List.map raw_parts ~f:(function
      | `Literal s -> `Literal s
      | `Hole "_" -> `Hole `Underscore
      | `Hole s ->
        (match Int.of_string_opt s with
         | Some n ->
           if n < 1
           then
             Location.raise_errorf
               ~loc
               "Numbered holes are 1-indexed and must be positive, got: %n"
               n
           else `Hole (`Numbered n)
         | None -> `Hole (`Named s)))
  in
  let unnamed_hole_count =
    List.count classified ~f:(function
      | `Hole (`Underscore | `Numbered _) -> true
      | `Literal _ | `Hole (`Named _) -> false)
  in
  List.map classified ~f:(function
    | `Literal s -> Part.Literal s
    | `Hole `Underscore ->
      if unnamed_hole_count > 1
      then
        Location.raise_errorf
          ~loc
          "Cannot use %%{_} if there's more than one unnamed hole, found %d such holes."
          unnamed_hole_count
      else Hole (Number 1)
    | `Hole (`Numbered n) ->
      if n > unnamed_hole_count
      then
        Location.raise_errorf
          ~loc
          "Used %d in a numbered hole, but there %s only %d numbered %s."
          n
          (if unnamed_hole_count = 1 then "is" else "are")
          unnamed_hole_count
          (if unnamed_hole_count = 1 then "hole" else "holes")
      else Hole (Number n)
    | `Hole (`Named name) -> Hole (Label name))
;;

(* Parse the format string into a list of [Part.t] with classified [Hole_key.t] values.
   Raises on parse errors and invalid hole names. *)
let parse { text; loc } = parse_raw text ~loc ~pos:0 ~acc:[] |> classify ~loc

(* ============================================================ *)
(* Step 2: Match parsers with holes *)
(* ============================================================ *)

(* Assign [Hole_key.t] values to each entry: [None] entries are numbered 1, 2, ... in list
   order; [Some name] entries become [Label name]. *)
let hole_keys_of_list parsers =
  List.folding_map parsers ~init:0 ~f:(fun numbered_count (label, expr) ->
    match label with
    | None ->
      let n = numbered_count + 1 in
      n, (Hole_key.Number n, expr)
    | Some name -> numbered_count, (Hole_key.Label name, expr))
;;

(* Build a map from [Hole_key.t] to [expression] by matching the caller's expressions
   against the holes that appear in the format string.

   Raises on duplicate holes, missing expressions, or extra expressions. *)
let validate_holes ~parts ~parser_map ~loc =
  let hole_key_set =
    let hole_keys =
      List.filter_map parts ~f:(function
        | Part.Hole key -> Some key
        | Literal _ -> None)
    in
    match List.find_all_dups hole_keys ~compare:Hole_key.compare with
    | [] -> Set.of_list (module Hole_key) hole_keys
    | _ :: _ as dups ->
      let dups = List.map dups ~f:Hole_key.to_string |> String.concat ~sep:", " in
      Location.raise_errorf
        ~loc
        "The following holes have been specified more than once: %s."
        dups
  in
  (* Validate: every parser has a corresponding hole in the format string *)
  Map.iter_keys parser_map ~f:(fun key ->
    if not (Set.mem hole_key_set key)
    then
      Location.raise_errorf
        ~loc
        "The format string is expected to contain the hole %%{%s} but no such hole found"
        (Hole_key.to_string_hum ~total_count:(Map.length parser_map) key));
  (* Validate: every hole in the format string has a corresponding parser *)
  Set.iter hole_key_set ~f:(fun key ->
    if not (Map.mem parser_map key)
    then
      Location.raise_errorf
        ~loc
        "The hole %%{%s} inside the format string does not correspond to any field"
        (Hole_key.to_string_hum ~total_count:(Set.length hole_key_set) key))
;;

(* ============================================================ *)
(* Step 3: Build the expression *)
(* ============================================================ *)

module Fields = struct
  type t =
    | None
    | Singleton of { parser : expression }
    | Many of
        { parsers : (string option * expression) list
        ; combine : (string option * expression) list -> expression
        }
end

let build_expression_none ~parts ~loc =
  let parser_exprs =
    List.map parts ~f:(function
      | Part.Literal text -> [%expr Unstring.lit [%e estring ~loc text]]
      | Hole _ -> failwith "ppx_unstring bug: no holes expected")
  in
  [%expr Unstring.all_unit [%e elist ~loc parser_exprs]]
;;

let build_expression_singleton ~parts ~parser ~loc =
  let var_name = Hole_key.var_name (Number 1) in
  let parser_exprs, patterns =
    List.map parts ~f:(function
      | Part.Literal text -> [%expr Unstring.lit [%e estring ~loc text]], [%pat? ()]
      | Hole _ -> parser, pvar ~loc var_name)
    |> List.unzip
  in
  match parser_exprs with
  | [ parser ] -> parser
  | _ ->
    [%expr
      Unstring.map_hlist [%e elist ~loc parser_exprs] ~f:(fun [%p plist ~loc patterns] ->
        [%e evar ~loc var_name])]
;;

let build_expression_many ~parts ~parser_map ~caller_parsers ~combine ~loc =
  let parser_exprs, patterns =
    List.map parts ~f:(function
      | Part.Literal text -> [%expr Unstring.lit [%e estring ~loc text]], [%pat? ()]
      | Hole key -> Map.find_exn parser_map key, pvar ~loc (Hole_key.var_name key))
    |> List.unzip
  in
  let hole_args =
    List.map caller_parsers ~f:(fun (key, _) ->
      Hole_key.label key, evar ~loc (Hole_key.var_name key))
  in
  [%expr
    Unstring.map_hlist [%e elist ~loc parser_exprs] ~f:(fun [%p plist ~loc patterns] ->
      [%e combine hole_args])]
;;

(* ============================================================ *)
(* build_unstring: public entry point *)
(* ============================================================ *)

let build_string ({ loc = string_loc; _ } as t) ~loc ~hole_string_exprs =
  let parts = parse t in
  let caller_exprs = hole_keys_of_list hole_string_exprs in
  let expr_map = Map.of_alist_exn (module Hole_key) caller_exprs in
  validate_holes ~parts ~parser_map:expr_map ~loc:string_loc;
  let string_parts =
    List.map parts ~f:(function
      | Part.Literal text -> estring ~loc text
      | Part.Hole key -> Map.find_exn expr_map key)
  in
  match string_parts with
  | [] -> estring ~loc ""
  | [ single ] -> single
  | _ -> [%expr String.concat ~sep:"" [%e elist ~loc string_parts]]
;;

let build_unstring ({ loc = string_loc; _ } as t) ~loc ~fields =
  let parts = parse t in
  match (fields : Fields.t) with
  | None ->
    validate_holes ~parts ~parser_map:(Map.empty (module Hole_key)) ~loc:string_loc;
    build_expression_none ~parts ~loc
  | Singleton { parser } ->
    let parser_map = Map.singleton (module Hole_key) (Number 1) parser in
    validate_holes ~parts ~parser_map ~loc:string_loc;
    build_expression_singleton ~parts ~parser ~loc
  | Many { parsers; combine } ->
    let caller_parsers = hole_keys_of_list parsers in
    let parser_map = Map.of_alist_exn (module Hole_key) caller_parsers in
    validate_holes ~parts ~parser_map ~loc:string_loc;
    build_expression_many ~parts ~parser_map ~caller_parsers ~combine ~loc
;;