[OCaml] Org mode parser.
1
(** Raw_ast to Ast normalization.
2
3
Transforms the flat [Raw_ast.document] produced by the structural parser
4
into the semantic [Ast.document] tree. This includes:
5
- building heading hierarchy from flat headings
6
- parsing heading titles for TODO/priority/tags/COMMENT
7
- inline parsing of text fields
8
- list nesting by indentation
9
- block classification
10
- planning and property extraction
11
- affiliated keyword attachment
12
- directive collection *)
13
14
(* ------------------------------------------------------------------ *)
15
(* Utility helpers *)
16
(* ------------------------------------------------------------------ *)
17
18
let string_lowercase = String.lowercase_ascii
19
20
(** Trim leading and trailing whitespace from a string. *)
21
let trim = String.trim
22
23
(** Split a string on the first space. *)
24
let split_first_word s =
25
let s = trim s in
26
match String.index_opt s ' ' with
27
| None -> (s, "")
28
| Some i -> (String.sub s 0 i, String.sub s (i + 1) (String.length s - i - 1))
29
30
(* ------------------------------------------------------------------ *)
31
(* Heading title parsing *)
32
(* ------------------------------------------------------------------ *)
33
34
(** Parse tags from the end of a heading title. Tags look like :tag1:tag2: at
35
the end of the line. *)
36
let extract_tags title =
37
let title = trim title in
38
let len = String.length title in
39
if len < 2 || title.[len - 1] <> ':' then (title, [])
40
else begin
41
(* Find the start of the tags section: look for a space followed by : *)
42
let rec find_tag_start i =
43
if i <= 0 then None
44
else if title.[i] = ' ' || title.[i] = '\t' then
45
if i + 1 < len && title.[i + 1] = ':' then Some (i + 1)
46
else find_tag_start (i - 1)
47
else find_tag_start (i - 1)
48
in
49
match find_tag_start (len - 2) with
50
| None ->
51
(* Check if the whole title is a tag string *)
52
if title.[0] = ':' then
53
let tag_str = String.sub title 1 (len - 2) in
54
let tags = String.split_on_char ':' tag_str in
55
if List.for_all (fun t -> String.length t > 0) tags then ("", tags)
56
else (title, [])
57
else (title, [])
58
| Some tag_start ->
59
let tag_str = String.sub title (tag_start + 1) (len - tag_start - 2) in
60
let tags = String.split_on_char ':' tag_str in
61
if List.for_all (fun t -> String.length t > 0) tags then
62
let title_part = trim (String.sub title 0 tag_start) in
63
(title_part, tags)
64
else (title, [])
65
end
66
67
(** Parse priority from the beginning of a heading title. Priority looks like
68
[#A] at the start. *)
69
let extract_priority title =
70
let title = trim title in
71
if
72
String.length title >= 4
73
&& title.[0] = '['
74
&& title.[1] = '#'
75
&& title.[3] = ']'
76
&& title.[2] >= 'A'
77
&& title.[2] <= 'Z'
78
then
79
let rest = trim (String.sub title 4 (String.length title - 4)) in
80
(Some title.[2], rest)
81
else (None, title)
82
83
(** Parse a heading raw title string into its components. Order: TODO
84
[#PRIORITY] COMMENT title :tags: *)
85
let parse_heading_title config raw_title =
86
let raw_title = trim raw_title in
87
(* Extract tags from end first *)
88
let title_no_tags, tags = extract_tags raw_title in
89
(* Extract TODO keyword from front *)
90
let todo, rest =
91
let first_word, remainder = split_first_word title_no_tags in
92
if first_word <> "" && Config.is_todo_keyword config first_word then
93
(Some first_word, remainder)
94
else (None, title_no_tags)
95
in
96
(* Extract priority *)
97
let priority, rest2 = extract_priority rest in
98
(* Extract COMMENT marker *)
99
let commented, rest3 =
100
let first_word, remainder = split_first_word rest2 in
101
if first_word = "COMMENT" then (true, remainder) else (false, rest2)
102
in
103
(* Parse remaining text as inline content *)
104
let title_inline =
105
if trim rest3 = "" then [] else Parse.inline (trim rest3)
106
in
107
(todo, priority, commented, title_inline, tags)
108
109
(* ------------------------------------------------------------------ *)
110
(* Checkbox parsing *)
111
(* ------------------------------------------------------------------ *)
112
113
let parse_checkbox = function
114
| Some "[ ]" -> Some Ast.Unchecked
115
| Some "[X]" | Some "[x]" -> Some Ast.Checked
116
| Some "[-]" -> Some Ast.Partial
117
| _ -> None
118
119
(* ------------------------------------------------------------------ *)
120
(* Descriptive list tag extraction *)
121
(* ------------------------------------------------------------------ *)
122
123
(** Extract a descriptive list tag from the first body line. A descriptive list
124
item has "TAG :: rest" in its text. *)
125
let extract_list_tag text =
126
match String.index_opt text ':' with
127
| None -> (None, text)
128
| Some i ->
129
if i + 1 < String.length text && text.[i + 1] = ':' then
130
let tag = trim (String.sub text 0 i) in
131
let rest_start = i + 2 in
132
let rest =
133
if rest_start < String.length text then
134
trim (String.sub text rest_start (String.length text - rest_start))
135
else ""
136
in
137
(Some tag, rest)
138
else (None, text)
139
140
(* ------------------------------------------------------------------ *)
141
(* List nesting *)
142
(* ------------------------------------------------------------------ *)
143
144
(** Determine list kind from the first item. *)
145
let determine_list_kind (items : Raw_ast.raw_list_item list) =
146
match items with
147
| [] -> Ast.Unordered
148
| first :: _ ->
149
let bullet = trim first.Raw_ast.rli_bullet in
150
if first.Raw_ast.rli_tag <> None then Ast.Descriptive
151
else begin
152
(* Check if bullet indicates an ordered list *)
153
let len = String.length bullet in
154
if len >= 2 then
155
let last = bullet.[len - 1] in
156
if last = '.' || last = ')' then
157
(* Check if prefix is digits or alpha *)
158
let prefix = String.sub bullet 0 (len - 1) in
159
let is_ordered =
160
String.length prefix > 0
161
&&
162
let c = prefix.[0] in
163
(c >= '0' && c <= '9')
164
|| (c >= 'a' && c <= 'z')
165
|| (c >= 'A' && c <= 'Z')
166
in
167
if is_ordered then Ast.Ordered else Ast.Unordered
168
else Ast.Unordered
169
else Ast.Unordered
170
end
171
172
(** Check if a raw list item text contains a descriptive tag (::). *)
173
let item_has_tag (item : Raw_ast.raw_list_item) =
174
item.Raw_ast.rli_tag <> None
175
||
176
match item.Raw_ast.rli_body_lines with
177
| first :: _ -> (
178
match String.index_opt first ':' with
179
| Some i -> i + 1 < String.length first && first.[i + 1] = ':'
180
| None -> false)
181
| [] -> false
182
183
(** Determine list kind considering descriptive tag detection. *)
184
let determine_list_kind_with_tags (items : Raw_ast.raw_list_item list) =
185
match items with
186
| [] -> Ast.Unordered
187
| first :: _ ->
188
if item_has_tag first then Ast.Descriptive else determine_list_kind items
189
190
(** Nest flat list items by indentation into a tree. Items with greater
191
indentation than the first item at this level become sub-items of the
192
preceding item. *)
193
let rec nest_list_items (items : Raw_ast.raw_list_item list) :
194
Ast.list_item list =
195
match items with
196
| [] -> []
197
| first :: _ ->
198
let base_indent = first.Raw_ast.rli_indent in
199
let rec group acc current_item remaining =
200
match remaining with
201
| [] ->
202
let finished = finish_item current_item [] in
203
List.rev (finished :: acc)
204
| next :: _ when next.Raw_ast.rli_indent > base_indent -> (
205
(* Collect all deeper items as sub-items of current_item *)
206
let sub_items, remaining' =
207
collect_sub_items base_indent remaining
208
in
209
let finished = finish_item current_item sub_items in
210
match remaining' with
211
| [] -> List.rev (finished :: acc)
212
| next_top :: rest_top -> group (finished :: acc) next_top rest_top)
213
| next :: rest ->
214
(* Same level: finish current, start new *)
215
let finished = finish_item current_item [] in
216
group (finished :: acc) next rest
217
in
218
group [] first (List.tl items)
219
220
and collect_sub_items base_indent items =
221
let rec collect acc = function
222
| [] -> (List.rev acc, [])
223
| item :: rest ->
224
if item.Raw_ast.rli_indent > base_indent then collect (item :: acc) rest
225
else (List.rev acc, item :: rest)
226
in
227
collect [] items
228
229
and finish_item (raw : Raw_ast.raw_list_item)
230
(sub_items : Raw_ast.raw_list_item list) : Ast.list_item =
231
let checkbox = parse_checkbox raw.Raw_ast.rli_checkbox in
232
(* Join body lines *)
233
let body_text = String.concat " " raw.Raw_ast.rli_body_lines in
234
(* Check for descriptive tag *)
235
let tag_opt, content_text =
236
match raw.Raw_ast.rli_tag with
237
| Some t -> (Some t, body_text)
238
| None -> extract_list_tag body_text
239
in
240
let tag_inline = Option.map (fun t -> Parse.inline t) tag_opt in
241
(* Build contents: paragraph from text + sub-list if any *)
242
let contents =
243
let text_elements =
244
if trim content_text = "" then []
245
else [ Ast.Paragraph (Parse.inline content_text, []) ]
246
in
247
let sub_list_elements =
248
if sub_items = [] then []
249
else
250
let kind = determine_list_kind_with_tags sub_items in
251
[ Ast.Plain_list (kind, nest_list_items sub_items) ]
252
in
253
text_elements @ sub_list_elements
254
in
255
{
256
Ast.li_bullet = raw.Raw_ast.rli_bullet;
257
li_counter_set = raw.Raw_ast.rli_counter_set;
258
li_checkbox = checkbox;
259
li_tag = tag_inline;
260
li_contents = contents;
261
}
262
263
(* ------------------------------------------------------------------ *)
264
(* Table normalization *)
265
(* ------------------------------------------------------------------ *)
266
267
(** Parse a table row string into cells. Row format: "| cell1 | cell2 | cell3 |"
268
*)
269
let parse_table_row_cells row_str =
270
(* Strip leading and trailing | *)
271
let s = trim row_str in
272
let s =
273
if String.length s > 0 && s.[0] = '|' then
274
String.sub s 1 (String.length s - 1)
275
else s
276
in
277
let s =
278
if String.length s > 0 && s.[String.length s - 1] = '|' then
279
String.sub s 0 (String.length s - 1)
280
else s
281
in
282
let parts = String.split_on_char '|' s in
283
List.map (fun cell -> Parse.inline (trim cell)) parts
284
285
let normalize_table_row (row : Raw_ast.raw_table_row) : Ast.table_row =
286
match row with
287
| Raw_ast.Raw_table_rule -> Ast.Table_row_rule
288
| Raw_ast.Raw_table_standard s ->
289
Ast.Table_row_standard (parse_table_row_cells s)
290
291
let normalize_table (rows : Raw_ast.raw_table_row list) : Ast.table =
292
{ Ast.rows = List.map normalize_table_row rows }
293
294
(* ------------------------------------------------------------------ *)
295
(* Block classification *)
296
(* ------------------------------------------------------------------ *)
297
298
(** Parse src block params: first word is language, rest is switches/arguments.
299
*)
300
let parse_src_params params_opt =
301
match params_opt with
302
| None -> (None, None, None)
303
| Some params ->
304
let params = trim params in
305
if params = "" then (None, None, None)
306
else
307
let lang, rest = split_first_word params in
308
let language = if lang = "" then None else Some lang in
309
let arguments = if trim rest = "" then None else Some (trim rest) in
310
(language, None, arguments)
311
312
(** Normalize a block body that contains recursive elements. *)
313
let rec normalize_recursive_body (elements : Raw_ast.raw_element list) config :
314
Ast.element list =
315
normalize_elements config elements
316
317
(** Classify a raw block into the appropriate Ast.block variant. *)
318
and classify_block (raw : Raw_ast.raw_block) config
319
(affiliated : Ast.affiliated list) : Ast.block =
320
let name = string_lowercase raw.rb_name in
321
match name with
322
| "src" ->
323
let body_str =
324
match raw.rb_body with
325
| Raw_ast.Opaque_body s -> s
326
| Raw_ast.Recursive_body _ -> ""
327
in
328
let language, switches, arguments = parse_src_params raw.rb_params in
329
Ast.Src_block
330
{
331
src_language = language;
332
src_switches = switches;
333
src_arguments = arguments;
334
src_value = body_str;
335
src_affiliated = affiliated;
336
}
337
| "example" ->
338
let body_str =
339
match raw.rb_body with
340
| Raw_ast.Opaque_body s -> s
341
| Raw_ast.Recursive_body _ -> ""
342
in
343
Ast.Example_block
344
{
345
ex_value = body_str;
346
ex_switches = raw.rb_params;
347
ex_affiliated = affiliated;
348
}
349
| "export" ->
350
let body_str =
351
match raw.rb_body with
352
| Raw_ast.Opaque_body s -> s
353
| Raw_ast.Recursive_body _ -> ""
354
in
355
let backend =
356
match raw.rb_params with
357
| None -> ""
358
| Some p -> fst (split_first_word p)
359
in
360
Ast.Export_block
361
{
362
exp_backend = backend;
363
exp_value = body_str;
364
exp_affiliated = affiliated;
365
}
366
| "comment" ->
367
let body_str =
368
match raw.rb_body with
369
| Raw_ast.Opaque_body s -> s
370
| Raw_ast.Recursive_body _ -> ""
371
in
372
Ast.Comment_block { cb_value = body_str; cb_affiliated = affiliated }
373
| "quote" ->
374
let contents =
375
match raw.rb_body with
376
| Raw_ast.Recursive_body elems -> normalize_recursive_body elems config
377
| Raw_ast.Opaque_body s ->
378
if trim s = "" then [] else [ Ast.Paragraph (Parse.inline s, []) ]
379
in
380
Ast.Quote_block { qt_contents = contents; qt_affiliated = affiliated }
381
| "center" ->
382
let contents =
383
match raw.rb_body with
384
| Raw_ast.Recursive_body elems -> normalize_recursive_body elems config
385
| Raw_ast.Opaque_body s ->
386
if trim s = "" then [] else [ Ast.Paragraph (Parse.inline s, []) ]
387
in
388
Ast.Center_block { cn_contents = contents; cn_affiliated = affiliated }
389
| "verse" ->
390
let inline =
391
match raw.rb_body with
392
| Raw_ast.Opaque_body s -> Parse.inline s
393
| Raw_ast.Recursive_body _ -> []
394
in
395
Ast.Verse_block { vs_contents = inline; vs_affiliated = affiliated }
396
| _ ->
397
let contents =
398
match raw.rb_body with
399
| Raw_ast.Recursive_body elems -> normalize_recursive_body elems config
400
| Raw_ast.Opaque_body s ->
401
if trim s = "" then [] else [ Ast.Paragraph (Parse.inline s, []) ]
402
in
403
Ast.Custom_block
404
{
405
cst_name = raw.rb_name;
406
cst_params = raw.rb_params;
407
cst_contents = contents;
408
cst_affiliated = affiliated;
409
}
410
411
(* ------------------------------------------------------------------ *)
412
(* Element normalization with affiliated keyword attachment *)
413
(* ------------------------------------------------------------------ *)
414
415
(** Normalize a list of raw elements into Ast elements. Handles affiliated
416
keyword collection and attachment. *)
417
and normalize_elements config (raw_elements : Raw_ast.raw_element list) :
418
Ast.element list =
419
let rec process acc affiliated = function
420
| [] ->
421
(* Any trailing affiliated keywords become plain keywords *)
422
let trailing =
423
List.rev_map
424
(fun (aff : Ast.affiliated) ->
425
Ast.Keyword { kw_key = aff.aff_name; kw_value = aff.aff_value })
426
affiliated
427
in
428
List.rev (trailing @ acc)
429
| Raw_ast.Raw_paragraph [] :: rest ->
430
(* Blank paragraph: skip it but flush any pending affiliated as keywords *)
431
if affiliated <> [] then begin
432
let kws =
433
List.rev_map
434
(fun (aff : Ast.affiliated) ->
435
Ast.Keyword { kw_key = aff.aff_name; kw_value = aff.aff_value })
436
affiliated
437
in
438
process (kws @ acc) [] rest
439
end
440
else process acc [] rest
441
| Raw_ast.Raw_affiliated (name, opt, value) :: rest ->
442
let aff =
443
{ Ast.aff_name = name; aff_optional = opt; aff_value = value }
444
in
445
process acc (aff :: affiliated) rest
446
| elem :: rest -> (
447
let normalized =
448
normalize_single_element config elem (List.rev affiliated)
449
in
450
match normalized with
451
| Some e -> process (e :: acc) [] rest
452
| None -> process acc [] rest)
453
in
454
process [] [] raw_elements
455
456
(** Normalize a single raw element into an Ast element. *)
457
and normalize_single_element config (elem : Raw_ast.raw_element)
458
(affiliated : Ast.affiliated list) : Ast.element option =
459
match elem with
460
| Raw_ast.Raw_paragraph [] -> None
461
| Raw_ast.Raw_paragraph lines ->
462
let text = String.concat " " lines in
463
let inline = Parse.inline text in
464
Some (Ast.Paragraph (inline, affiliated))
465
| Raw_ast.Raw_list_items items ->
466
let kind = determine_list_kind_with_tags items in
467
let nested = nest_list_items items in
468
Some (Ast.Plain_list (kind, nested))
469
| Raw_ast.Raw_table rows -> Some (Ast.Table (normalize_table rows))
470
| Raw_ast.Raw_block raw_block ->
471
Some (Ast.Block (classify_block raw_block config affiliated))
472
| Raw_ast.Raw_drawer drawer ->
473
let contents = normalize_elements config drawer.rd_contents in
474
Some (Ast.Drawer (drawer.rd_name, contents))
475
| Raw_ast.Raw_keyword (key, value) ->
476
Some (Ast.Keyword { kw_key = key; kw_value = value })
477
| Raw_ast.Raw_affiliated (name, _opt, value) ->
478
(* Standalone affiliated keyword not attached to anything → keyword *)
479
Some (Ast.Keyword { kw_key = name; kw_value = value })
480
| Raw_ast.Raw_comment lines -> Some (Ast.Comment lines)
481
| Raw_ast.Raw_fixed_width lines -> Some (Ast.Fixed_width lines)
482
| Raw_ast.Raw_horizontal_rule -> Some Ast.Horizontal_rule
483
| Raw_ast.Raw_planning _ ->
484
(* Planning outside heading context becomes a paragraph *)
485
None
486
| Raw_ast.Raw_property_drawer _ ->
487
(* Property drawer outside heading context: ignore *)
488
None
489
| Raw_ast.Raw_heading _ ->
490
(* Should not appear in element lists *)
491
None
492
493
(* ------------------------------------------------------------------ *)
494
(* Planning and property extraction from heading body *)
495
(* ------------------------------------------------------------------ *)
496
497
(** Extract a timestamp from a raw planning entry. Uses the inline parser which
498
handles timestamps. *)
499
let extract_timestamp ts_str =
500
let inlines = Parse.inline ts_str in
501
let rec find_ts = function
502
| [] -> None
503
| Ast.Timestamp ts :: _ -> Some ts
504
| _ :: rest -> find_ts rest
505
in
506
find_ts inlines
507
508
(** Extract planning information from a list of raw_planning entries. *)
509
let extract_planning (entries : Raw_ast.raw_planning list) : Ast.planning option
510
=
511
match entries with
512
| [] -> None
513
| _ ->
514
let planning =
515
List.fold_left
516
(fun acc (entry : Raw_ast.raw_planning) ->
517
match entry.rpl_keyword with
518
| "DEADLINE" ->
519
{
520
acc with
521
Ast.deadline = extract_timestamp entry.rpl_timestamp;
522
}
523
| "SCHEDULED" ->
524
{
525
acc with
526
Ast.scheduled = extract_timestamp entry.rpl_timestamp;
527
}
528
| "CLOSED" ->
529
{ acc with Ast.closed = extract_timestamp entry.rpl_timestamp }
530
| _ -> acc)
531
{ Ast.deadline = None; scheduled = None; closed = None }
532
entries
533
in
534
if
535
planning.deadline = None && planning.scheduled = None
536
&& planning.closed = None
537
then None
538
else Some planning
539
540
(** Extract properties from drawer contents. In a PROPERTIES drawer, each entry
541
was parsed as Raw_keyword(name, value). *)
542
let extract_properties (elements : Raw_ast.raw_element list) : Ast.property list
543
=
544
List.filter_map
545
(fun elem ->
546
match elem with
547
| Raw_ast.Raw_keyword (name, value) ->
548
let prop_value = if trim value = "" then None else Some value in
549
Some { Ast.prop_name = name; prop_value }
550
| Raw_ast.Raw_paragraph [] -> None
551
| _ -> None)
552
elements
553
554
(** Process heading body to extract planning, properties, and remaining
555
elements. *)
556
let process_heading_body config (body : Raw_ast.raw_element list) =
557
let planning, rest1 =
558
match body with
559
| Raw_ast.Raw_planning entries :: rest -> (extract_planning entries, rest)
560
| _ -> (None, body)
561
in
562
let properties, rest2 =
563
match rest1 with
564
| Raw_ast.Raw_drawer { rd_name; rd_contents } :: rest
565
when string_lowercase rd_name = "properties" ->
566
(extract_properties rd_contents, rest)
567
| _ -> ([], rest1)
568
in
569
let elements = normalize_elements config rest2 in
570
(planning, properties, elements)
571
572
(* ------------------------------------------------------------------ *)
573
(* Heading hierarchy *)
574
(* ------------------------------------------------------------------ *)
575
576
(** Build heading hierarchy from a flat list of raw headings. Each heading's
577
children are subsequent headings with a greater level, until a heading of
578
equal or lesser level is encountered. *)
579
let build_heading_tree config (raw_headings : Raw_ast.raw_heading list) :
580
Ast.heading list =
581
let rec process_headings headings =
582
match headings with
583
| [] -> []
584
| raw :: rest ->
585
let children_raw, siblings_raw =
586
collect_children raw.Raw_ast.rh_level rest
587
in
588
let todo, priority, commented, title, tags =
589
parse_heading_title config raw.Raw_ast.rh_raw_title
590
in
591
let planning, properties, contents =
592
process_heading_body config raw.Raw_ast.rh_body
593
in
594
let children = process_headings children_raw in
595
let heading =
596
{
597
Ast.level = raw.Raw_ast.rh_level;
598
todo;
599
priority;
600
commented;
601
title;
602
tags;
603
planning;
604
properties;
605
contents;
606
children;
607
}
608
in
609
heading :: process_headings siblings_raw
610
and collect_children parent_level headings =
611
(* Collect all headings that are deeper than parent_level *)
612
let rec collect children remaining =
613
match remaining with
614
| [] -> (List.rev children, [])
615
| h :: _ when h.Raw_ast.rh_level <= parent_level ->
616
(List.rev children, remaining)
617
| h :: rest -> collect (h :: children) rest
618
in
619
collect [] headings
620
in
621
process_headings raw_headings
622
623
(* ------------------------------------------------------------------ *)
624
(* Directive extraction *)
625
(* ------------------------------------------------------------------ *)
626
627
(** Known directive keywords that should be extracted from the preamble. *)
628
let directive_keywords =
629
[
630
"TITLE";
631
"AUTHOR";
632
"DATE";
633
"EMAIL";
634
"LANGUAGE";
635
"DESCRIPTION";
636
"OPTIONS";
637
"STARTUP";
638
"FILETAGS";
639
"CATEGORY";
640
"PROPERTY";
641
"ARCHIVE";
642
"COLUMNS";
643
"LINK";
644
"PRIORITIES";
645
"TAGS";
646
"EXPORT_FILE_NAME";
647
]
648
649
(** Check if a keyword should be treated as a directive. *)
650
let is_directive_keyword key =
651
List.mem (String.uppercase_ascii key) directive_keywords
652
653
(** Extract directives from the leading keywords of the preamble. Directives are
654
keywords that appear before any non-keyword/non-blank element in the
655
preamble. *)
656
let extract_directives (raw_elements : Raw_ast.raw_element list) :
657
Ast.directive list * Raw_ast.raw_element list =
658
let rec collect_leading directives remaining =
659
match remaining with
660
| Raw_ast.Raw_keyword (key, value) :: rest when is_directive_keyword key ->
661
let dir = { Ast.dir_key = key; dir_value = value } in
662
collect_leading (dir :: directives) rest
663
| Raw_ast.Raw_paragraph [] :: rest ->
664
(* Skip blank paragraphs while collecting directives *)
665
collect_leading directives rest
666
| Raw_ast.Raw_affiliated _ :: rest ->
667
(* Skip affiliated keywords in directive zone *)
668
collect_leading directives rest
669
| _ -> (List.rev directives, remaining)
670
in
671
collect_leading [] raw_elements
672
673
(* ------------------------------------------------------------------ *)
674
(* Top-level normalization *)
675
(* ------------------------------------------------------------------ *)
676
677
let normalize (config : Config.t) (raw : Raw_ast.document) : Ast.document =
678
(* Extract directives from preamble *)
679
let directives, remaining_preamble = extract_directives raw.rd_preamble in
680
(* Normalize remaining preamble elements *)
681
let preamble = normalize_elements config remaining_preamble in
682
(* Build heading hierarchy *)
683
let headings = build_heading_tree config raw.rd_headings in
684
{ Ast.directives; preamble; headings }
685