View raw

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