(* Inline Menhir grammar for Org Mode inline objects. Parses inline tokens into Ast.inline list. Key design: the inline lexer pre-scans for matched emphasis pairs and only emits OPEN/CLOSE tokens for valid pairs. Unmatched delimiters become TEXT tokens from the lexer. This eliminates ambiguity in the grammar. *) %{ open Ast let merge_plain items = let rec loop acc = function | [] -> List.rev acc | Plain s1 :: Plain s2 :: rest -> loop acc (Plain (s1 ^ s2) :: rest) | item :: rest -> loop (item :: acc) rest in loop [] items let parse_link_target s = if String.length s >= 8 && String.sub s 0 8 = "https://" then Url s else if String.length s >= 7 && String.sub s 0 7 = "http://" then Url s else if String.length s >= 6 && String.sub s 0 6 = "ftp://" then Url s else if String.length s >= 5 && String.sub s 0 5 = "file:" then File (String.sub s 5 (String.length s - 5)) else match String.index_opt s ':' with | Some i when i > 0 -> let proto = String.sub s 0 i in let path = String.sub s (i + 1) (String.length s - i - 1) in Protocol (proto, path) | _ -> Internal s let rec parse_timestamp_string s = let len = String.length s in if len < 12 then None else let is_active = s.[0] = '<' in (* Check for range *) let range_sep = if is_active then ">--<" else "]--[" in let range_idx = let slen = String.length range_sep in let rec find i = if i + slen > len then None else if String.sub s i slen = range_sep then Some i else find (i + 1) in find 0 in match range_idx with | Some idx -> let ts1 = String.sub s 0 (idx + 1) in let ts2 = String.sub s (idx + String.length range_sep - 1) (len - idx - String.length range_sep + 1) in (match parse_single_date ts1, parse_single_date ts2 with | Some d1, Some d2 -> if is_active then Some (Active_range (d1, d2)) else Some (Inactive_range (d1, d2)) | _ -> None) | None -> match parse_single_date s with | Some date -> if is_active then Some (Active (date, None)) else Some (Inactive (date, None)) | None -> None and parse_single_date s = let len = String.length s in if len < 12 then None else let inner = String.sub s 1 (len - 2) in let parts = String.split_on_char ' ' inner |> List.filter (fun p -> p <> "") in match parts with | date_part :: rest -> let date_parts = String.split_on_char '-' date_part in (match date_parts with | [y; m; d] -> (try let year = int_of_string y in let month = int_of_string m in let day = int_of_string d in let dayname = ref None in let hour = ref None in let minute = ref None in List.iter (fun part -> if String.length part = 5 && part.[2] = ':' then begin (try hour := Some (int_of_string (String.sub part 0 2)); minute := Some (int_of_string (String.sub part 3 2)) with Failure _ -> ()) end else if String.length part >= 2 then dayname := Some part ) rest; Some { year; month; day; dayname = !dayname; hour = !hour; minute = !minute } with Failure _ -> None) | _ -> None) | [] -> None %} %token TEXT %token STAR_OPEN STAR_CLOSE %token SLASH_OPEN SLASH_CLOSE %token UNDER_OPEN UNDER_CLOSE %token PLUS_OPEN PLUS_CLOSE %token TILDE %token EQUALS %token LINK_OPEN LINK_CLOSE LINK_SEP %token TIMESTAMP %token ANGLE_LINK %token PLAIN_LINK %token LINE_BREAK %token NEWLINE %token EOF %start inline_content %% inline_content: | items = list(inline_item) EOF { merge_plain items } ; inline_item: | t = TEXT { Plain t } | STAR_OPEN contents = list(inline_item) STAR_CLOSE { Bold (merge_plain contents) } | SLASH_OPEN contents = list(inline_item) SLASH_CLOSE { Italic (merge_plain contents) } | UNDER_OPEN contents = list(inline_item) UNDER_CLOSE { Underline (merge_plain contents) } | PLUS_OPEN contents = list(inline_item) PLUS_CLOSE { Strikethrough (merge_plain contents) } | c = TILDE { Code c } | v = EQUALS { Verbatim v } | LINK_OPEN target = link_path LINK_CLOSE { Link { target = parse_link_target target; description = None } } | LINK_OPEN target = link_path LINK_SEP desc = list(inline_item) LINK_CLOSE { Link { target = parse_link_target target; description = Some (merge_plain desc) } } | link = ANGLE_LINK { Link { target = parse_link_target link; description = None } } | link = PLAIN_LINK { Link { target = parse_link_target link; description = None } } | ts = TIMESTAMP { match parse_timestamp_string ts with | Some t -> Timestamp t | None -> Plain ts } | LINE_BREAK { Line_break } | NEWLINE { Plain " " } ; link_path: | parts = nonempty_list(link_path_part) { String.concat "" parts } ; link_path_part: | t = TEXT { t } | STAR_OPEN { "*" } | STAR_CLOSE { "*" } | SLASH_OPEN { "/" } | SLASH_CLOSE { "/" } | UNDER_OPEN { "_" } | UNDER_CLOSE { "_" } | PLUS_OPEN { "+" } | PLUS_CLOSE { "+" } | c = TILDE { "~" ^ c ^ "~" } | v = EQUALS { "=" ^ v ^ "=" } | link = PLAIN_LINK { link } | link = ANGLE_LINK { "<" ^ link ^ ">" } | ts = TIMESTAMP { ts } | NEWLINE { " " } | LINE_BREAK { "\\\\" } ;