From 1e2ea9ec56434b96d7c8f3c9c0ffb27bb7fc4ee3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 05:22:01 +0000 Subject: [PATCH 1/4] perf(outline): iterative ref scan + nested [[ ]] without Angstrom Convert page/block-ref and markup scans to while-loops so a long unmatched [[ cannot blow the stack. Build Nested_link in the fast path (depth-capped) instead of falling back to Angstrom, and skip the extra backslash pre-pass. Co-authored-by: Tienson Qin --- lib/syntax/outline_inline.ml | 159 +++++++++++++++++++++++------------ 1 file changed, 106 insertions(+), 53 deletions(-) diff --git a/lib/syntax/outline_inline.ml b/lib/syntax/outline_inline.ml index 9619837a..e0b58a7d 100644 --- a/lib/syntax/outline_inline.ml +++ b/lib/syntax/outline_inline.ml @@ -12,15 +12,15 @@ let is_outline_special = function let may_have_outline_markup _config s = let n = String.length s in - let rec loop i = - if i >= n then - false - else if is_outline_special s.[i] then - true + let i = ref 0 in + let found = ref false in + while (not !found) && !i < n do + if is_outline_special (String.unsafe_get s !i) then + found := true else - loop (i + 1) - in - loop 0 + incr i + done; + !found let skip_plain_run = skip_while (fun c -> (not (is_outline_special c)) && not (is_whitespace c)) @@ -68,56 +68,68 @@ let tag_trail = function true | _ -> false +(** Iterative scan; returns [(end_pos, has_inner_page_ref)]. *) let find_page_ref_end s i = let n = String.length s in if i + 1 >= n || s.[i] <> '[' || s.[i + 1] <> '[' then None else - let rec loop j depth = - if j + 1 >= n then - None - else if s.[j] = '[' && s.[j + 1] = '[' then - loop (j + 2) (depth + 1) - else if s.[j] = ']' && s.[j + 1] = ']' then - if depth = 1 then - Some (j + 2) + let j = ref (i + 2) in + let depth = ref 1 in + let inner = ref false in + let found = ref None in + while !found = None && !j + 1 < n do + let c = String.unsafe_get s !j in + if c = '[' && String.unsafe_get s (!j + 1) = '[' then ( + incr depth; + inner := true; + j := !j + 2 + ) else if c = ']' && String.unsafe_get s (!j + 1) = ']' then ( + decr depth; + if !depth = 0 then + found := Some (!j + 2) else - loop (j + 2) (depth - 1) - else - loop (j + 1) depth - in - loop (i + 2) 1 + j := !j + 2 + ) else + incr j + done; + match !found with + | Some e -> Some (e, !inner) + | None -> None let find_block_ref_end s i = let n = String.length s in if i + 1 >= n || s.[i] <> '(' || s.[i + 1] <> '(' then None else - let rec loop j = - if j + 1 >= n then - None - else if s.[j] = ')' && s.[j + 1] = ')' then - Some (j + 2) + let j = ref (i + 2) in + let found = ref None in + while !found = None && !j + 1 < n do + if String.unsafe_get s !j = ')' && String.unsafe_get s (!j + 1) = ')' then + found := Some (!j + 2) else - loop (j + 1) - in - loop (i + 2) + incr j + done; + !found + +let empty_plain = [ Inline.Plain "" ] +let max_nested_ref_depth = 32 let page_ref_link name = Inline.Link { url = Inline.Page_ref name - ; label = [ Inline.Plain "" ] + ; label = empty_plain ; title = None - ; full_text = "[[" ^ name ^ "]]" + ; full_text = Printf.sprintf "[[%s]]" name ; metadata = "" } let block_ref_link id = Inline.Link { url = Inline.Block_ref id - ; label = [ Inline.Plain "" ] + ; label = empty_plain ; title = None - ; full_text = "((" ^ id ^ "))" + ; full_text = Printf.sprintf "((%s))" id ; metadata = "" } @@ -133,49 +145,90 @@ let strip_tag_trail raw = in strip raw -(** Fast path for #tag / [[page]] / ((block)). Returns None when markdown - links or nested-page hashtags need the angstrom parser. +let rec build_nested_link s start end_ depth = + if depth > max_nested_ref_depth then + None + else + let content = String.sub s start (end_ - start) in + let inner_off = start + 2 in + let inner_end = end_ - 2 in + let children = ref [] in + let i = ref inner_off in + let ok = ref true in + while !i < inner_end && !ok do + if !i + 1 < inner_end && s.[!i] = '[' && s.[!i + 1] = '[' then ( + match find_page_ref_end s !i with + | Some (e, _) when e <= inner_end -> ( + match build_nested_link s !i e (depth + 1) with + | Some nl -> + children := Nested_link.Nested_link (nl, None) :: !children; + i := e + | None -> ok := false) + | _ -> ok := false + ) else + let j = ref !i in + while + !j < inner_end + && not (!j + 1 < inner_end && s.[!j] = '[' && s.[!j + 1] = '[') + do + incr j + done; + if !j > !i then ( + children := Nested_link.Label (String.sub s !i (!j - !i)) :: !children; + i := !j + ) else + ok := false + done; + if (not !ok) || !children = [] then + None + else + Some { Nested_link.content; children = List.rev !children } + +(** Fast path for #tag / [[page]] / ((block)) / nested [[a [[b]]]]. + Returns None when markdown links or nested-page hashtags need Angstrom. Scans [s] from [off] with length [len] (no need to sub the whole title). *) let try_fast_scan_range s off len = if len < 0 || off < 0 || off + len > String.length s then None - else if - let rec has_bs i = i < off + len && (s.[i] = '\\' || has_bs (i + 1)) in - has_bs off - then - None else let end_ = off + len in let acc = ref [] in let i = ref off in let complex = ref false in while !i < end_ && not !complex do - match s.[!i] with + match String.unsafe_get s !i with + | '\\' -> complex := true | '#' when !i + 1 < end_ && (not (is_ws s.[!i + 1])) && s.[!i + 1] <> '#' -> let start = !i + 1 in let j = ref start in let has_bracket = ref false in - while !j < end_ && not (is_ws s.[!j]) do - if s.[!j] = '[' then has_bracket := true; + while !j < end_ && not (is_ws (String.unsafe_get s !j)) do + if String.unsafe_get s !j = '[' then has_bracket := true; incr j done; if !has_bracket then complex := true else let name = strip_tag_trail (String.sub s start (!j - start)) in - if name <> "" then acc := Inline.Tag [ Inline.Plain name ] :: !acc; + if name <> "" then + acc := (Inline.Tag [ Inline.Plain name ], None) :: !acc; i := !j | '[' when !i + 1 < end_ && s.[!i + 1] = '[' -> ( - (* find_page_ref_end walks to string end; clamp by checking within range *) match find_page_ref_end s !i with - | Some e when e <= end_ -> + | Some (e, inner) when e <= end_ -> let name = String.sub s (!i + 2) (e - !i - 4) in - (* Nested [[…]] needs Nested_link — fall back to Angstrom. *) - if String.contains name '[' then - complex := true - else ( - acc := page_ref_link name :: !acc; + if inner then ( + match build_nested_link s !i e 0 with + | Some nl when List.length nl.children > 1 -> + acc := (Inline.Nested_link nl, None) :: !acc; + i := e + | Some _ -> + acc := (page_ref_link name, None) :: !acc; + i := e + | None -> complex := true + ) else ( + acc := (page_ref_link name, None) :: !acc; i := e ) | _ -> complex := true) @@ -184,7 +237,7 @@ let try_fast_scan_range s off len = match find_block_ref_end s !i with | Some e when e <= end_ -> let id = String.sub s (!i + 2) (e - !i - 4) in - acc := block_ref_link id :: !acc; + acc := (block_ref_link id, None) :: !acc; i := e | _ -> incr i) | _ -> incr i @@ -192,7 +245,7 @@ let try_fast_scan_range s off len = if !complex then None else - Some (Type_op.inline_list_with_none_pos (List.rev !acc)) + Some (List.rev !acc) let try_fast_scan s = try_fast_scan_range s 0 (String.length s) From d146b6a7d354fdc44a193495f9b3cc5bb5a1c987 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 05:26:33 +0000 Subject: [PATCH 2/4] perf(outline): first-char dispatch and cheaper per-line work Classify lines by first non-space char instead of trying every constructor. Boundary checks no longer fully parse headings or run Inline.parse. Outline properties use the outline inline scanner, titles are scanned in-place, line_starts is skipped, and list/ref recursion is capped. Co-authored-by: Tienson Qin --- lib/syntax/md_outline.ml | 659 +++++++++++++++++++++++++-------------- 1 file changed, 431 insertions(+), 228 deletions(-) diff --git a/lib/syntax/md_outline.ml b/lib/syntax/md_outline.ml index abbdba08..80ed11c5 100644 --- a/lib/syntax/md_outline.ml +++ b/lib/syntax/md_outline.ml @@ -9,6 +9,9 @@ open Conf let dummy = Pos.dummy_pos let with_pos t = (t, dummy) +let empty_meta : Type.meta = { timestamps = []; properties = [] } +let empty_para = Paragraph [] +let max_list_depth = 64 let ensure_trailing_nl s = let n = String.length s in @@ -60,50 +63,89 @@ let rstrip_cr s = let skip_spaces s i = let n = String.length s in - let rec loop j = - if j < n && is_space_char s.[j] then - loop (j + 1) - else - j - in - loop i + let j = ref i in + while !j < n && is_space_char (String.unsafe_get s !j) do + incr j + done; + !j let indent_len s = let n = String.length s in - let rec loop i = - if i < n && is_space_char s.[i] then - loop (i + 1) - else - i - in - loop 0 + let i = ref 0 in + while !i < n && is_space_char (String.unsafe_get s !i) do + incr i + done; + !i let starts_with_at s i prefix = let plen = String.length prefix in let n = String.length s in - i + plen <= n - && - let rec loop k = - if k = plen then - true - else if s.[i + k] = prefix.[k] then - loop (k + 1) - else - false - in - loop 0 + if i + plen > n then + false + else + let k = ref 0 in + let ok = ref true in + while !ok && !k < plen do + if String.unsafe_get s (i + !k) <> String.unsafe_get prefix !k then + ok := false + else + incr k + done; + !ok let is_blank_line s = let n = String.length s in - let rec loop i = - if i >= n then - true - else if is_space_char s.[i] then - loop (i + 1) + let i = ref 0 in + let blank = ref true in + while !blank && !i < n do + if not (is_space_char (String.unsafe_get s !i)) then + blank := false else - false - in - loop 0 + incr i + done; + !blank + +(** Single-pass split; strips CR; reuses [""] for empty lines. *) +let split_lines_array input = + let n = String.length input in + let nlines = ref 1 in + for i = 0 to n - 1 do + if String.unsafe_get input i = '\n' then incr nlines + done; + let arr = Array.make !nlines "" in + let idx = ref 0 in + let start = ref 0 in + for i = 0 to n - 1 do + if String.unsafe_get input i = '\n' then ( + let len = i - !start in + arr.(!idx) <- + (if len = 0 then + "" + else if String.unsafe_get input (i - 1) = '\r' then + if len = 1 then + "" + else + String.sub input !start (len - 1) + else + String.sub input !start len); + incr idx; + start := i + 1 + ) + done; + let len = n - !start in + arr.(!idx) <- + (if len = 0 then + "" + else if String.unsafe_get input (n - 1) = '\r' then + if len = 1 then + "" + else + String.sub input !start (len - 1) + else if !start = 0 then + input + else + String.sub input !start len); + arr let is_fence_line line = let ind = indent_len line in @@ -117,10 +159,36 @@ let is_quote_line line = let ind = indent_len line in ind < String.length line && line.[ind] = '>' -let is_properties_start line = - String.lowercase_ascii (String.trim line) = ":properties:" +let eq_ci_trimmed line word = + let n = String.length line in + let i = skip_spaces line 0 in + let j = ref n in + while !j > i && is_space_char (String.unsafe_get line (!j - 1)) do + decr j + done; + let wlen = String.length word in + if !j - i <> wlen then + false + else + let k = ref 0 in + let ok = ref true in + while !ok && !k < wlen do + let c = String.unsafe_get line (i + !k) in + let c = + if c >= 'A' && c <= 'Z' then + Char.unsafe_chr (Char.code c + 32) + else + c + in + if c <> String.unsafe_get word !k then + ok := false + else + incr k + done; + !ok -let is_end_mark line = String.lowercase_ascii (String.trim line) = ":end:" +let is_properties_start line = eq_ci_trimmed line ":properties:" +let is_end_mark line = eq_ci_trimmed line ":end:" let is_list_item_prefix line = let ind = indent_len line in @@ -140,20 +208,110 @@ let is_list_item_prefix line = else false -let outline_inlines config s = - if s = "" then +let looks_like_dash_heading line = + let ind = indent_len line in + let n = String.length line in + if ind >= n || line.[ind] <> '-' then + false + else if ind + 1 >= n then + true + else + is_space_char line.[ind + 1] + +let looks_like_atx_heading line = + let ind = indent_len line in + let n = String.length line in + if ind >= n || line.[ind] <> '#' then + false + else + let j = ref ind in + while !j < n && line.[!j] = '#' do + incr j + done; + !j > ind && (!j >= n || is_space_char line.[!j]) + +let looks_like_md_property line = + let ind = indent_len line in + let n = String.length line in + if ind >= n then + false + else + let j = ref ind in + while + !j < n + && line.[!j] <> ':' + && (not (is_space_char line.[!j])) + && line.[!j] <> '\n' + do + incr j + done; + !j > ind && !j + 1 < n && line.[!j] = ':' && line.[!j + 1] = ':' + +let looks_like_org_style_prop line = + let ind = indent_len line in + let n = String.length line in + if ind + 2 >= n || line.[ind] <> '#' || line.[ind + 1] <> '+' then + false + else + let j = ref (ind + 2) in + while !j < n && line.[!j] <> ':' && not (is_space_char line.[!j]) do + incr j + done; + !j > ind + 2 && !j < n && line.[!j] = ':' + +let looks_like_footnote line = + let ind = indent_len line in + let n = String.length line in + if ind + 3 > n || line.[ind] <> '[' || line.[ind + 1] <> '^' then + false + else + try + let close = String.index_from line (ind + 2) ']' in + close + 1 < n && line.[close + 1] = ':' + with + | Not_found -> false + +let line_may_be_property line = + looks_like_md_property line || looks_like_org_style_prop line + +let outline_inlines_range config s off len = + if len <= 0 then [] - else if Outline_inline.may_have_outline_markup config s then - match Outline_inline.try_fast_scan s with + else + match Outline_inline.try_fast_scan_range s off len with | Some r -> r | None -> ( + let sub = + if off = 0 && len = String.length s then + s + else + String.sub s off len + in match - Angstrom.parse_string ~consume:All (Outline_inline.parse config) s + Angstrom.parse_string ~consume:All + (Outline_inline.parse_angstrom config) + sub with | Ok r -> r | Error _ -> []) - else + +let outline_inlines config s = outline_inlines_range config s 0 (String.length s) + +let outline_property_references config s = + if s = "" then [] + else + let n = String.length s in + if n >= 2 && s.[0] = '"' && s.[n - 1] = '"' then + [] + else + List.map fst (outline_inlines config s) + +let property_refs config s = + if config.parse_outline_only then + outline_property_references config s + else + Property.property_references config s let full_inlines config s = if s = "" then @@ -193,7 +351,7 @@ let heading ~outline_only ~level ~unordered ~size ~marker ~priority ~title = "" else anchor_of_title title) - ; meta = { timestamps = []; properties = [] } + ; meta = empty_meta ; numbering = None ; unordered ; size @@ -203,21 +361,31 @@ let try_marker s i = if i >= String.length s then None else - let rec loop k = - if k >= Array.length markers then - None - else - let m = markers.(k) in - if starts_with_at s i m then - let j = i + String.length m in - if j >= String.length s || is_space_char s.[j] then - Some (m, j) + match s.[i] with + | 'I' + | 'C' + | 'W' + | 'S' + | 'D' + | 'T' + | 'N' + | 'L' -> + let rec loop k = + if k >= Array.length markers then + None + else + let m = markers.(k) in + if starts_with_at s i m then + let j = i + String.length m in + if j >= String.length s || is_space_char s.[j] then + Some (m, j) + else + loop (k + 1) else loop (k + 1) - else - loop (k + 1) - in - loop 0 + in + loop 0 + | _ -> None let try_priority s i = let n = String.length s in @@ -238,30 +406,27 @@ let parse_marker_priority_title config s i = | Some (p, j) -> (Some p, skip_spaces s j) | None -> (None, i) in - let title = - if i >= String.length s then - "" - else - String.sub s i (String.length s - i) - in - let title_inlines, keep_title = - if title = "" then - ([], true) - else - match title.[0] with - | '>' -> - (* Leave markdown quote for the following block (Angstrom parity). *) - ([], false) - | '`' - | '~' - when String.length title >= 3 - && title.[1] = title.[0] - && title.[2] = title.[0] -> - (* Fenced code opener on the heading line. *) - ([], false) - | _ -> (content_inlines config title, true) - in - (marker, priority, title_inlines, keep_title, title) + let n = String.length s in + if i >= n then + (marker, priority, [], true, "") + else + match s.[i] with + | '>' -> + (* Leave markdown quote for the following block (Angstrom parity). *) + (marker, priority, [], false, String.sub s i (n - i)) + | '`' + | '~' + when n - i >= 3 && s.[i + 1] = s.[i] && s.[i + 2] = s.[i] -> + (* Fenced code opener on the heading line. *) + (marker, priority, [], false, String.sub s i (n - i)) + | _ -> + let title_inlines = + if config.parse_outline_only then + outline_inlines_range config s i (n - i) + else + content_inlines config (String.sub s i (n - i)) + in + (marker, priority, title_inlines, true, "") let parse_size_hashes s i = let n = String.length s in @@ -375,7 +540,7 @@ let try_md_property config line = else String.trim (String.sub line rest_i (n - rest_i)) in - Some (key, value, Property.property_references config value) + Some (key, value, property_refs config value) else None @@ -426,7 +591,7 @@ let try_org_drawer_prop_line config line = else String.trim (String.sub line rest_i (n - rest_i)) in - Some (key, value, Property.property_references config value) + Some (key, value, property_refs config value) else None @@ -498,15 +663,13 @@ let collect_properties config lines i = in loop i [] -let is_block_boundary config line = +let is_block_boundary _config line = is_blank_line line - || try_dash_heading config line <> None - || try_atx_heading config line <> None + || looks_like_dash_heading line + || looks_like_atx_heading line || is_fence_line line || is_quote_line line || is_list_item_prefix line - || is_properties_start line - || try_md_property config line <> None - || try_org_style_prop line <> None - || try_footnote_line config line <> None + || is_properties_start line || looks_like_md_property line + || looks_like_org_style_prop line || looks_like_footnote line let collect_paragraph_lines config lines i = let n = Array.length lines in @@ -519,16 +682,25 @@ let collect_paragraph_lines config lines i = loop (j + 1) (lines.(j) :: acc) in let ls, j = loop i [] in - let content = String.concat "\n" ls in - (* When another block follows, each line was newline-terminated in the - source — Angstrom records a final Break_Line. *) - let content = - if (not config.parse_outline_only) && j < n && content <> "" then - ensure_trailing_nl content - else - content - in - (content_paragraph config content, j) + if + config.parse_outline_only + && not + (List.exists + (fun l -> Outline_inline.may_have_outline_markup config l) + ls) + then + (empty_para, j) + else + let content = String.concat "\n" ls in + (* When another block follows, each line was newline-terminated in the + source — Angstrom records a final Break_Line. *) + let content = + if (not config.parse_outline_only) && j < n && content <> "" then + ensure_trailing_nl content + else + content + in + (content_paragraph config content, j) let skip_fence_body lines i = let rec loop j = @@ -602,8 +774,8 @@ let collect_src ~line_starts lines i = let quote_continuation_stop config line = (* Match Block.md_blockquote: stop only on new block markers. *) let trimmed = String.trim line in - try_dash_heading config line <> None - || try_atx_heading config line <> None + looks_like_dash_heading line + || looks_like_atx_heading line || is_list_item_prefix line || is_fence_line line || is_properties_start line || starts_with_at line 0 "- " || starts_with_at line 0 "# " || starts_with_at line 0 "id:: " @@ -722,63 +894,119 @@ let make_list_item config ~indent ~ordered ~number content children = ; ordered } -let rec parse_list_items config lines i min_indent = - let items = ref [] in - let j = ref i in - let continue = ref true in - while !continue && !j < Array.length lines do - let line = lines.(!j) in - if is_blank_line line then - incr j - else if - try_dash_heading config line <> None - || try_atx_heading config line <> None - then - continue := false - else if is_list_item_prefix line then - let indent, ordered, number, content = parse_list_item_line line in - if indent < min_indent then +let rec parse_list_items ?(depth = 0) config lines i min_indent = + if depth >= max_list_depth then + ([], i) + else + let items = ref [] in + let j = ref i in + let continue = ref true in + while !continue && !j < Array.length lines do + let line = lines.(!j) in + if is_blank_line line then + incr j + else if looks_like_dash_heading line || looks_like_atx_heading line then continue := false - else ( - incr j; - let children, j' = - if !j < Array.length lines && is_list_item_prefix lines.(!j) then - let child_indent = indent_len lines.(!j) in - if child_indent > indent then - parse_list_items config lines !j child_indent + else if is_list_item_prefix line then + let indent, ordered, number, content = parse_list_item_line line in + if indent < min_indent then + continue := false + else ( + incr j; + let children, j' = + if !j < Array.length lines && is_list_item_prefix lines.(!j) then + let child_indent = indent_len lines.(!j) in + if child_indent > indent then + parse_list_items ~depth:(depth + 1) config lines !j child_indent + else + ([], !j) else ([], !j) + in + j := j'; + items := + make_list_item config ~indent ~ordered ~number content children + :: !items + ) + else + continue := false + done; + (List.rev !items, !j) + +let emit_heading acc i lines n line_starts src_end_pos config line = + match try_dash_heading config line with + | Some (h, rest) -> + acc := with_pos h :: !acc; + incr i; + (match rest with + | Nothing -> () + | Fence hdr -> + if config.parse_outline_only then + i := skip_fence_body lines !i + else + let body_i = !i in + let body_start_pos = + if body_i < n then + line_starts.(body_i) else - ([], !j) + line_starts.(!i - 1) + String.length lines.(!i - 1) + 1 + in + let body_end_pos = src_end_pos body_i in + let src, j = + collect_src_from_header ~body_start_pos ~body_end_pos lines body_i hdr in - j := j'; - items := - make_list_item config ~indent ~ordered ~number content children - :: !items - ) + acc := with_pos src :: !acc; + i := j + | Quote_line qline -> + if not config.parse_outline_only then + let q, j = collect_quote config ~first_line:qline lines !i in + acc := with_pos q :: !acc; + i := j); + true + | None -> false + +let emit_properties_or_paragraph acc i lines config line = + if line_may_be_property line then ( + match collect_properties config lines !i with + | (_ :: _ as kvs), j -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j; + true + | [], _ -> false + ) else + false + +let emit_paragraph_or_latex acc i lines config line = + match + if config.parse_outline_only then + None else - continue := false - done; - (List.rev !items, !j) + try_latex_environment line + with + | Some latex -> + acc := with_pos latex :: !acc; + incr i + | None -> + let p, j = collect_paragraph_lines config lines !i in + acc := with_pos p :: !acc; + i := j let parse config input = - let raw_lines = String.split_on_char '\n' input in - let lines = Array.of_list (List.map rstrip_cr raw_lines) in + let lines = split_lines_array input in let n = Array.length lines in + let outline = config.parse_outline_only in let line_starts = - let arr = Array.make (max n 1) 0 in - let pos = ref 0 in - for idx = 0 to n - 1 do - arr.(idx) <- !pos; - let nl = - if idx + 1 < n then - 1 - else - 0 - in - pos := !pos + String.length lines.(idx) + nl - done; - arr + if outline then + [||] + else + let arr = Array.make (max n 1) 0 in + let pos = ref 0 in + for idx = 0 to n - 1 do + arr.(idx) <- !pos; + let nl = if idx + 1 < n then 1 else 0 in + pos := !pos + String.length lines.(idx) + nl + done; + arr in let src_end_pos body_i = let rec find j = @@ -798,91 +1026,66 @@ let parse config input = let i = ref 0 in while !i < n do let line = lines.(!i) in - if is_blank_line line then + let ind = indent_len line in + if ind >= String.length line then incr i else - match try_dash_heading config line with - | Some (h, rest) -> ( - acc := with_pos h :: !acc; - incr i; - match rest with - | Nothing -> () - | Fence hdr -> - if config.parse_outline_only then - i := skip_fence_body lines !i - else - let body_i = !i in - let body_start_pos = - if body_i < n then - line_starts.(body_i) - else - line_starts.(!i - 1) + String.length lines.(!i - 1) + 1 - in - let body_end_pos = src_end_pos body_i in - let src, j = - collect_src_from_header ~body_start_pos ~body_end_pos lines body_i - hdr - in - acc := with_pos src :: !acc; - i := j - | Quote_line qline -> - if config.parse_outline_only then - () - else - let q, j = collect_quote config ~first_line:qline lines !i in - acc := with_pos q :: !acc; - i := j) - | None -> ( + match line.[ind] with + | '-' -> + if + not + (emit_heading acc i lines n line_starts src_end_pos config line) + then + if not (emit_properties_or_paragraph acc i lines config line) then + emit_paragraph_or_latex acc i lines config line + | '#' -> ( match try_atx_heading config line with | Some h -> acc := with_pos h :: !acc; incr i - | None -> ( - match try_footnote_line config line with - | Some fn -> - acc := with_pos fn :: !acc; - incr i - | None -> ( - match collect_properties_drawer config lines !i with - | Some (kvs, j) -> - acc := with_pos (Property_Drawer kvs) :: !acc; - i := j - | None -> ( - match collect_properties config lines !i with - | (_ :: _ as kvs), j -> - acc := with_pos (Property_Drawer kvs) :: !acc; - i := j - | [], _ -> ( - if is_fence_line line then ( - if config.parse_outline_only then - i := skip_fence lines !i - else - let src, j = collect_src ~line_starts lines !i in - acc := with_pos src :: !acc; - i := j - ) else if is_quote_line line then ( - let q, j = collect_quote config lines !i in - acc := with_pos q :: !acc; - i := j - ) else if is_list_item_prefix line then ( - let items, j = - parse_list_items config lines !i (indent_len line) - in - acc := with_pos (List items) :: !acc; - i := j - ) else - match - if config.parse_outline_only then - None - else - try_latex_environment line - with - | Some latex -> - acc := with_pos latex :: !acc; - incr i - | None -> - let p, j = collect_paragraph_lines config lines !i in - acc := with_pos p :: !acc; - i := j))))) + | None -> + if not (emit_properties_or_paragraph acc i lines config line) then + emit_paragraph_or_latex acc i lines config line) + | '[' -> ( + match try_footnote_line config line with + | Some fn -> + acc := with_pos fn :: !acc; + incr i + | None -> + if not (emit_properties_or_paragraph acc i lines config line) then + emit_paragraph_or_latex acc i lines config line) + | ':' -> ( + match collect_properties_drawer config lines !i with + | Some (kvs, j) -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + | None -> emit_paragraph_or_latex acc i lines config line) + | '`' -> + if is_fence_line line then + if outline then + i := skip_fence lines !i + else + let src, j = collect_src ~line_starts lines !i in + acc := with_pos src :: !acc; + i := j + else if not (emit_properties_or_paragraph acc i lines config line) then + emit_paragraph_or_latex acc i lines config line + | '>' -> + let q, j = collect_quote config lines !i in + acc := with_pos q :: !acc; + i := j + | '+' + | '*' + | '0' .. '9' -> + if is_list_item_prefix line then ( + let items, j = parse_list_items config lines !i ind in + acc := with_pos (List items) :: !acc; + i := j + ) else if not (emit_properties_or_paragraph acc i lines config line) + then + emit_paragraph_or_latex acc i lines config line + | _ -> + if not (emit_properties_or_paragraph acc i lines config line) then + emit_paragraph_or_latex acc i lines config line done; List.rev !acc From 219cd0c71d1dcf789d3cf6d160a27c0f793b5082 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 05:33:30 +0000 Subject: [PATCH 3/4] fix: keep Angstrom fallback for escaped page refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [[\]]] must still parse as Page_ref "]" — any backslash in the scan range disables the fast path (iterative check, not recursive). Co-authored-by: Tienson Qin --- lib/syntax/md_outline.ml | 2 +- lib/syntax/outline_inline.ml | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/syntax/md_outline.ml b/lib/syntax/md_outline.ml index 80ed11c5..c1d33517 100644 --- a/lib/syntax/md_outline.ml +++ b/lib/syntax/md_outline.ml @@ -771,7 +771,7 @@ let collect_src ~line_starts lines i = collect_src_from_header ~body_start_pos ~body_end_pos lines body_i fence_header -let quote_continuation_stop config line = +let quote_continuation_stop _config line = (* Match Block.md_blockquote: stop only on new block markers. *) let trimmed = String.trim line in looks_like_dash_heading line diff --git a/lib/syntax/outline_inline.ml b/lib/syntax/outline_inline.ml index e0b58a7d..4a5497cb 100644 --- a/lib/syntax/outline_inline.ml +++ b/lib/syntax/outline_inline.ml @@ -187,9 +187,22 @@ let rec build_nested_link s start end_ depth = (** Fast path for #tag / [[page]] / ((block)) / nested [[a [[b]]]]. Returns None when markdown links or nested-page hashtags need Angstrom. Scans [s] from [off] with length [len] (no need to sub the whole title). *) +let has_backslash s off end_ = + let i = ref off in + let found = ref false in + while (not !found) && !i < end_ do + if String.unsafe_get s !i = '\\' then + found := true + else + incr i + done; + !found + let try_fast_scan_range s off len = if len < 0 || off < 0 || off + len > String.length s then None + else if has_backslash s off (off + len) then + None else let end_ = off + len in let acc = ref [] in From b33a0f32f012281110e22636f07c129b2499c0d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 05:41:11 +0000 Subject: [PATCH 4/4] perf(outline): intern repeated refs and parse headings from offsets Reuse page/tag/block-ref nodes and common heading records within a parse. Outline mode indexes newlines and classifies/parses headings from source spans so most lines are never copied into strings. Co-authored-by: Tienson Qin --- lib/syntax/md_outline.ml | 666 +++++++++++++++++++++++++++++++++-- lib/syntax/outline_inline.ml | 224 ++++++++++-- 2 files changed, 836 insertions(+), 54 deletions(-) diff --git a/lib/syntax/md_outline.ml b/lib/syntax/md_outline.ml index c1d33517..050fb361 100644 --- a/lib/syntax/md_outline.ml +++ b/lib/syntax/md_outline.ml @@ -61,14 +61,58 @@ let rstrip_cr s = else s -let skip_spaces s i = - let n = String.length s in +let skip_spaces_lim s i lim = let j = ref i in - while !j < n && is_space_char (String.unsafe_get s !j) do + while !j < lim && is_space_char (String.unsafe_get s !j) do incr j done; !j +let skip_spaces s i = skip_spaces_lim s i (String.length s) + +type line_index = + { src : string + ; starts : int array (* starts.(n) = String.length src *) + ; n : int + } + +let index_lines src = + let n = String.length src in + let nlines = ref 1 in + for i = 0 to n - 1 do + if String.unsafe_get src i = '\n' then incr nlines + done; + let starts = Array.make (!nlines + 1) 0 in + let idx = ref 1 in + for i = 0 to n - 1 do + if String.unsafe_get src i = '\n' then ( + starts.(!idx) <- i + 1; + incr idx + ) + done; + starts.(!nlines) <- n; + { src; starts; n = !nlines } + +(** Content span [off, lim) without trailing CR/LF. *) +let line_lim idx i = + let off = idx.starts.(i) in + let next = idx.starts.(i + 1) in + if next > off && String.unsafe_get idx.src (next - 1) = '\n' then + let e = next - 1 in + if e > off && String.unsafe_get idx.src (e - 1) = '\r' then + (off, e - 1) + else + (off, e) + else + (off, next) + +let line_at idx i = + let off, lim = line_lim idx i in + if lim <= off then + "" + else + String.sub idx.src off (lim - off) + let indent_len s = let n = String.length s in let i = ref 0 in @@ -339,23 +383,40 @@ let quote_paragraph config s = in Paragraph (content_inlines config s) +let heading_cache : (int * bool * Type.inline_list, Type.t) Hashtbl.t option ref + = + ref None + let heading ~outline_only ~level ~unordered ~size ~marker ~priority ~title = - Heading - { level - ; marker - ; priority - ; title - ; tags = [] - ; anchor = - (if outline_only then - "" - else - anchor_of_title title) - ; meta = empty_meta - ; numbering = None - ; unordered - ; size - } + let make () = + Heading + { level + ; marker + ; priority + ; title + ; tags = [] + ; anchor = + (if outline_only then + "" + else + anchor_of_title title) + ; meta = empty_meta + ; numbering = None + ; unordered + ; size + } + in + if outline_only && marker = None && priority = None && size = None then + match !heading_cache with + | Some tbl -> ( + try Hashtbl.find tbl (level, unordered, title) with + | Not_found -> + let h = make () in + Hashtbl.add tbl (level, unordered, title) h; + h) + | None -> make () + else + make () let try_marker s i = if i >= String.length s then @@ -394,6 +455,147 @@ let try_priority s i = else None +let try_marker_lim s i lim = + if i >= lim then + None + else + match s.[i] with + | 'I' + | 'C' + | 'W' + | 'S' + | 'D' + | 'T' + | 'N' + | 'L' -> + let rec loop k = + if k >= Array.length markers then + None + else + let m = markers.(k) in + let mlen = String.length m in + if i + mlen <= lim && starts_with_at s i m then + let j = i + mlen in + if j >= lim || is_space_char s.[j] then + Some (m, j) + else + loop (k + 1) + else + loop (k + 1) + in + loop 0 + | _ -> None + +let try_priority_lim s i lim = + if i + 3 < lim && s.[i] = '[' && s.[i + 1] = '#' && s.[i + 3] = ']' then + Some (s.[i + 2], i + 4) + else + None + +let parse_size_hashes_lim s i lim = + if i >= lim || s.[i] <> '#' then + (None, i) + else + let j = ref i in + while !j < lim && s.[!j] = '#' do + incr j + done; + let count = !j - i in + if !j >= lim || is_space_char s.[!j] then + (Some count, !j) + else + (None, i) + +type heading_rest = + | Nothing + | Fence of string + | Quote_line of string + +let parse_marker_priority_title_lim config s i lim = + let i = skip_spaces_lim s i lim in + let marker, i = + match try_marker_lim s i lim with + | Some (m, j) -> (Some m, skip_spaces_lim s j lim) + | None -> (None, i) + in + let priority, i = + match try_priority_lim s i lim with + | Some (p, j) -> (Some p, skip_spaces_lim s j lim) + | None -> (None, i) + in + if i >= lim then + (marker, priority, [], true, "") + else + match s.[i] with + | '>' -> (marker, priority, [], false, String.sub s i (lim - i)) + | '`' + | '~' + when lim - i >= 3 && s.[i + 1] = s.[i] && s.[i + 2] = s.[i] -> + (marker, priority, [], false, String.sub s i (lim - i)) + | _ -> + let title_inlines = + if config.parse_outline_only then + outline_inlines_range config s i (lim - i) + else + content_inlines config (String.sub s i (lim - i)) + in + (marker, priority, title_inlines, true, "") + +let try_dash_heading_lim config s off lim = + let ind = skip_spaces_lim s off lim in + if ind >= lim || s.[ind] <> '-' then + None + else if ind + 1 < lim && not (is_space_char s.[ind + 1]) then + None + else + let i = skip_spaces_lim s (ind + 1) lim in + let size, i = parse_size_hashes_lim s i lim in + let marker, priority, title, keep_title, raw_title = + parse_marker_priority_title_lim config s i lim + in + let rest = + if keep_title || raw_title = "" then + Nothing + else if raw_title.[0] = '>' then + Quote_line raw_title + else if + (raw_title.[0] = '`' || raw_title.[0] = '~') + && String.length raw_title >= 3 + && raw_title.[1] = raw_title.[0] + && raw_title.[2] = raw_title.[0] + then + Fence raw_title + else + Nothing + in + Some + ( heading ~outline_only:config.parse_outline_only + ~level:(ind - off + 1) ~unordered:true ~size ~marker ~priority ~title + , rest ) + +let try_atx_heading_lim config s off lim = + let ind = skip_spaces_lim s off lim in + if ind >= lim || s.[ind] <> '#' then + None + else + let j = ref ind in + while !j < lim && s.[!j] = '#' do + incr j + done; + let size = !j - ind in + if size = 0 then + None + else if !j < lim && not (is_space_char s.[!j]) then + None + else + let marker, priority, title, _, _ = + parse_marker_priority_title_lim config s !j lim + in + Some + (heading ~outline_only:config.parse_outline_only + ~level:(ind - off + 1) ~unordered:false ~size:(Some size) ~marker + ~priority ~title) + let parse_marker_priority_title config s i = let i = skip_spaces s i in let marker, i = @@ -443,13 +645,6 @@ let parse_size_hashes s i = else (None, i) -(** [Some (heading, rest)] where [rest] is a fence header or quote line left - on the same source line after the heading marker. *) -type heading_rest = - | Nothing - | Fence of string - | Quote_line of string - let try_dash_heading config line = let ind = indent_len line in let n = String.length line in @@ -991,7 +1186,414 @@ let emit_paragraph_or_latex acc i lines config line = acc := with_pos p :: !acc; i := j -let parse config input = +let is_fence_span s off lim = + let ind = skip_spaces_lim s off lim in + ind + 3 <= lim + && s.[ind] = '`' + && s.[ind + 1] = '`' + && s.[ind + 2] = '`' + +let is_properties_start_span s off lim = + eq_ci_trimmed (if lim <= off then "" else String.sub s off (lim - off)) + ":properties:" + +let try_md_property_lim config s off lim = + let i = skip_spaces_lim s off lim in + if i >= lim then + None + else + let key_start = i in + let j = ref i in + while + !j < lim && s.[!j] <> ':' && (not (is_space_char s.[!j])) && s.[!j] <> '\n' + do + incr j + done; + if !j = key_start then + None + else if !j + 1 < lim && s.[!j] = ':' && s.[!j + 1] = ':' then + let key = String.sub s key_start (!j - key_start) in + let rest_i = skip_spaces_lim s (!j + 2) lim in + let value = + if rest_i >= lim then + "" + else + String.trim (String.sub s rest_i (lim - rest_i)) + in + Some (key, value, property_refs config value) + else + None + +let try_org_style_lim s off lim = + let ind = skip_spaces_lim s off lim in + if ind + 2 >= lim || s.[ind] <> '#' || s.[ind + 1] <> '+' then + None + else + let i = ind + 2 in + let j = ref i in + while !j < lim && s.[!j] <> ':' && not (is_space_char s.[!j]) do + incr j + done; + if !j > i && !j < lim && s.[!j] = ':' then + let name = String.sub s i (!j - i) in + let rest_i = skip_spaces_lim s (!j + 1) lim in + let value = + if rest_i >= lim then + "" + else + String.sub s rest_i (lim - rest_i) + in + Some (name, value, []) + else + None + +let looks_like_md_property_span s off lim = + let i = skip_spaces_lim s off lim in + if i >= lim then + false + else + let j = ref i in + while + !j < lim && s.[!j] <> ':' && (not (is_space_char s.[!j])) && s.[!j] <> '\n' + do + incr j + done; + !j > i && !j + 1 < lim && s.[!j] = ':' && s.[!j + 1] = ':' + +let looks_like_org_style_span s off lim = + let ind = skip_spaces_lim s off lim in + if ind + 2 >= lim || s.[ind] <> '#' || s.[ind + 1] <> '+' then + false + else + let j = ref (ind + 2) in + while !j < lim && s.[!j] <> ':' && not (is_space_char s.[!j]) do + incr j + done; + !j > ind + 2 && !j < lim && s.[!j] = ':' + +let is_list_span s off lim = + let ind = skip_spaces_lim s off lim in + if ind + 2 > lim then + false + else + let c = s.[ind] in + if (c = '+' || c = '*') && is_space_char s.[ind + 1] then + true + else if c >= '0' && c <= '9' then ( + let j = ref (ind + 1) in + while !j < lim && s.[!j] >= '0' && s.[!j] <= '9' do + incr j + done; + !j < lim && s.[!j] = '.' && !j + 1 < lim && is_space_char s.[!j + 1] + ) else + false + +let is_block_boundary_span s off lim = + if off >= lim then + true + else + let ind = skip_spaces_lim s off lim in + if ind >= lim then + true + else + match s.[ind] with + | '-' -> ind + 1 >= lim || is_space_char s.[ind + 1] + | '#' -> + let j = ref ind in + while !j < lim && s.[!j] = '#' do + incr j + done; + !j > ind + && (!j >= lim || is_space_char s.[!j]) + || looks_like_org_style_span s off lim + | '`' -> is_fence_span s off lim + | '>' -> true + | '+' + | '*' + | '0' .. '9' -> + is_list_span s off lim + | ':' -> + is_properties_start_span s off lim + | '[' -> + ind + 2 < lim && s.[ind + 1] = '^' + && + (try + let close = String.index_from s (ind + 2) ']' in + close < lim && close + 1 < lim && s.[close + 1] = ':' + with + | Not_found -> false) + | _ -> looks_like_md_property_span s off lim + +let collect_properties_idx config idx i = + let rec loop j acc = + if j >= idx.n then + (List.rev acc, j) + else + let off, lim = line_lim idx j in + match try_md_property_lim config idx.src off lim with + | Some kv -> loop (j + 1) (kv :: acc) + | None -> ( + match try_org_style_lim idx.src off lim with + | Some kv -> loop (j + 1) (kv :: acc) + | None -> (List.rev acc, j)) + in + loop i [] + +let collect_paragraph_idx config idx i = + let rec loop j = + if j >= idx.n then + j + else + let off, lim = line_lim idx j in + if is_block_boundary_span idx.src off lim then + j + else + loop (j + 1) + in + let j = loop i in + let has = ref false in + let k = ref i in + while !k < j && not !has do + let off, lim = line_lim idx !k in + if + Outline_inline.may_have_outline_markup_range idx.src off + (max 0 (lim - off)) + then + has := true + else + incr k + done; + if not !has then + (empty_para, j) + else + let buf = Buffer.create 64 in + for k = i to j - 1 do + if k > i then Buffer.add_char buf '\n'; + let off, lim = line_lim idx k in + if lim > off then Buffer.add_substring buf idx.src off (lim - off) + done; + (content_paragraph config (Buffer.contents buf), j) + +let skip_fence_idx idx i = + let rec loop j = + if j >= idx.n then + j + else + let off, lim = line_lim idx j in + if is_fence_span idx.src off lim then + j + 1 + else + loop (j + 1) + in + loop i + +let collect_quote_idx config idx i = + let rec loop j acc = + if j >= idx.n then + (List.rev acc, j) + else + let off, lim = line_lim idx j in + let ind = skip_spaces_lim idx.src off lim in + if ind < lim && idx.src.[ind] = '>' then + let body_i = skip_spaces_lim idx.src (ind + 1) lim in + let body = + if body_i >= lim then + "" + else + String.sub idx.src body_i (lim - body_i) + in + loop (j + 1) (body :: acc) + else + (List.rev acc, j) + in + let bodies, j = loop i [] in + if + not + (List.exists + (fun l -> Outline_inline.may_have_outline_markup config l) + bodies) + then + (Quote [ empty_para ], j) + else + let content = String.concat "\n" bodies in + (Quote [ quote_paragraph config content ], j) + +let rec parse_list_items_idx ?(depth = 0) config idx i min_indent = + if depth >= max_list_depth then + ([], i) + else + let items = ref [] in + let j = ref i in + let continue = ref true in + while !continue && !j < idx.n do + let off, lim = line_lim idx !j in + let ind = skip_spaces_lim idx.src off lim in + if ind >= lim then + incr j + else if + (idx.src.[ind] = '-' && (ind + 1 >= lim || is_space_char idx.src.[ind + 1])) + || idx.src.[ind] = '#' + && + let t = ref ind in + while !t < lim && idx.src.[!t] = '#' do + incr t + done; + !t > ind && (!t >= lim || is_space_char idx.src.[!t]) + then + continue := false + else if is_list_span idx.src off lim then ( + let line = line_at idx !j in + let indent, ordered, number, content = parse_list_item_line line in + if indent < min_indent then + continue := false + else ( + incr j; + let children, j' = + if !j < idx.n && is_list_span idx.src (fst (line_lim idx !j)) (snd (line_lim idx !j)) + then + let child_off, _ = line_lim idx !j in + let child_indent = + skip_spaces_lim idx.src child_off (snd (line_lim idx !j)) + - child_off + in + if child_indent > indent then + parse_list_items_idx ~depth:(depth + 1) config idx !j child_indent + else + ([], !j) + else + ([], !j) + in + j := j'; + items := + make_list_item config ~indent ~ordered ~number content children + :: !items + ) + ) else + continue := false + done; + (List.rev !items, !j) + +let parse_outline config input = + let idx = index_lines input in + let n = idx.n in + let acc = ref [] in + let i = ref 0 in + while !i < n do + let off, lim = line_lim idx !i in + let ind = skip_spaces_lim idx.src off lim in + if ind >= lim then + incr i + else + match idx.src.[ind] with + | '-' -> ( + match try_dash_heading_lim config idx.src off lim with + | Some (h, rest) -> + acc := with_pos h :: !acc; + incr i; + (match rest with + | Nothing -> () + | Fence _ -> i := skip_fence_idx idx !i + | Quote_line _ -> ()) + | None -> ( + match collect_properties_idx config idx !i with + | (_ :: _ as kvs), j -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + | [], _ -> + let p, j = collect_paragraph_idx config idx !i in + acc := with_pos p :: !acc; + i := j)) + | '#' -> ( + match try_atx_heading_lim config idx.src off lim with + | Some h -> + acc := with_pos h :: !acc; + incr i + | None -> ( + match collect_properties_idx config idx !i with + | (_ :: _ as kvs), j -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + | [], _ -> + let p, j = collect_paragraph_idx config idx !i in + acc := with_pos p :: !acc; + i := j)) + | '[' -> + let line = line_at idx !i in + (match try_footnote_line config line with + | Some fn -> + acc := with_pos fn :: !acc; + incr i + | None -> ( + match collect_properties_idx config idx !i with + | (_ :: _ as kvs), j -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + | [], _ -> + let p, j = collect_paragraph_idx config idx !i in + acc := with_pos p :: !acc; + i := j)) + | ':' -> + if is_properties_start_span idx.src off lim then ( + let rec loop j acc_kv = + if j >= n then + (List.rev acc_kv, j) + else + if eq_ci_trimmed (line_at idx j) ":end:" then + (List.rev acc_kv, j + 1) + else + match try_org_drawer_prop_line config (line_at idx j) with + | Some kv -> loop (j + 1) (kv :: acc_kv) + | None -> loop (j + 1) acc_kv + in + let kvs, j = loop (!i + 1) [] in + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + ) else + let p, j = collect_paragraph_idx config idx !i in + acc := with_pos p :: !acc; + i := j + | '`' when is_fence_span idx.src off lim -> + i := skip_fence_idx idx (!i + 1) + | '>' -> + let q, j = collect_quote_idx config idx !i in + acc := with_pos q :: !acc; + i := j + | '+' + | '*' + | '0' .. '9' -> + if is_list_span idx.src off lim then ( + let items, j = parse_list_items_idx config idx !i (ind - off) in + acc := with_pos (List items) :: !acc; + i := j + ) else ( + match collect_properties_idx config idx !i with + | (_ :: _ as kvs), j -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + | [], _ -> + let p, j = collect_paragraph_idx config idx !i in + acc := with_pos p :: !acc; + i := j + ) + | _ -> ( + match collect_properties_idx config idx !i with + | (_ :: _ as kvs), j -> + acc := with_pos (Property_Drawer kvs) :: !acc; + i := j + | [], _ -> + let p, j = collect_paragraph_idx config idx !i in + acc := with_pos p :: !acc; + i := j) + done; + List.rev !acc + +let rec parse_body config input = + if config.parse_outline_only then + parse_outline config input + else + parse_body_full config input + +and parse_body_full config input = let lines = split_lines_array input in let n = Array.length lines in let outline = config.parse_outline_only in @@ -1089,3 +1691,13 @@ let parse config input = emit_paragraph_or_latex acc i lines config line done; List.rev !acc + +let parse config input = + if config.parse_outline_only then + Outline_inline.with_intern (fun () -> + heading_cache := Some (Hashtbl.create 1024); + Fun.protect + ~finally:(fun () -> heading_cache := None) + (fun () -> parse_body config input)) + else + parse_body config input diff --git a/lib/syntax/outline_inline.ml b/lib/syntax/outline_inline.ml index 4a5497cb..d38443a8 100644 --- a/lib/syntax/outline_inline.ml +++ b/lib/syntax/outline_inline.ml @@ -10,11 +10,11 @@ let is_outline_special = function true | _ -> false -let may_have_outline_markup _config s = - let n = String.length s in - let i = ref 0 in +let may_have_outline_markup_range s off len = + let end_ = off + len in + let i = ref off in let found = ref false in - while (not !found) && !i < n do + while (not !found) && !i < end_ do if is_outline_special (String.unsafe_get s !i) then found := true else @@ -22,6 +22,9 @@ let may_have_outline_markup _config s = done; !found +let may_have_outline_markup _config s = + may_have_outline_markup_range s 0 (String.length s) + let skip_plain_run = skip_while (fun c -> (not (is_outline_special c)) && not (is_whitespace c)) @@ -115,35 +118,195 @@ let find_block_ref_end s i = let empty_plain = [ Inline.Plain "" ] let max_nested_ref_depth = 32 -let page_ref_link name = +type 'a rtab = + { mutable data : (string * 'a) list array + ; mutable count : int + } + +let rtab_create n = { data = Array.make n []; count = 0 } + +let hash_range s off len = + let h = ref 0 in + for i = 0 to len - 1 do + h := + (!h * 31 + Char.code (String.unsafe_get s (off + i))) land 0x3fffffff + done; + !h + +let range_eq s off key = + let n = String.length key in + let i = ref 0 in + let ok = ref true in + while !ok && !i < n do + if String.unsafe_get s (off + !i) <> String.unsafe_get key !i then + ok := false + else + incr i + done; + !ok + +let rtab_find tbl s off len = + let i = hash_range s off len land (Array.length tbl.data - 1) in + let rec look = function + | [] -> None + | (k, v) :: rest -> + if String.length k = len && range_eq s off k then + Some v + else + look rest + in + look tbl.data.(i) + +let rtab_add tbl key v = + let n = Array.length tbl.data in + if tbl.count > n then ( + let n' = n * 2 in + let data' = Array.make n' [] in + Array.iter + (fun bucket -> + List.iter + (fun ((k, _) as pair) -> + let i = hash_range k 0 (String.length k) land (n' - 1) in + data'.(i) <- pair :: data'.(i)) + bucket) + tbl.data; + tbl.data <- data' + ); + let i = hash_range key 0 (String.length key) land (Array.length tbl.data - 1) in + tbl.data.(i) <- (key, v) :: tbl.data.(i); + tbl.count <- tbl.count + 1 + +type intern = + { pages : Inline.t rtab + ; tags : Inline.t rtab + ; blocks : Inline.t rtab + ; titles : (Inline.t * Inline.t, Inline.t_with_pos list) Hashtbl.t + } + +let make_intern () = + { pages = rtab_create 256 + ; tags = rtab_create 64 + ; blocks = rtab_create 256 + ; titles = Hashtbl.create 1024 + } + +let current_intern : intern option ref = ref None + +let with_intern f = + current_intern := Some (make_intern ()); + Fun.protect ~finally:(fun () -> current_intern := None) f + +let page_ref_link_fresh name full_text = Inline.Link { url = Inline.Page_ref name ; label = empty_plain ; title = None - ; full_text = Printf.sprintf "[[%s]]" name + ; full_text ; metadata = "" } -let block_ref_link id = +let page_ref_link name = + match !current_intern with + | Some t -> ( + match rtab_find t.pages name 0 (String.length name) with + | Some v -> v + | None -> + let v = page_ref_link_fresh name (Printf.sprintf "[[%s]]" name) in + rtab_add t.pages name v; + v) + | None -> page_ref_link_fresh name (Printf.sprintf "[[%s]]" name) + +let page_ref_link_range s start end_ = + let name_off = start + 2 in + let name_len = end_ - start - 4 in + match !current_intern with + | Some t -> ( + match rtab_find t.pages s name_off name_len with + | Some v -> v + | None -> + let name = String.sub s name_off name_len in + let full_text = String.sub s start (end_ - start) in + let v = page_ref_link_fresh name full_text in + rtab_add t.pages name v; + v) + | None -> + let name = String.sub s name_off name_len in + let full_text = String.sub s start (end_ - start) in + page_ref_link_fresh name full_text + +let block_ref_link_fresh id full_text = Inline.Link { url = Inline.Block_ref id ; label = empty_plain ; title = None - ; full_text = Printf.sprintf "((%s))" id + ; full_text ; metadata = "" } +let block_ref_link id = + match !current_intern with + | Some t -> ( + match rtab_find t.blocks id 0 (String.length id) with + | Some v -> v + | None -> + let v = block_ref_link_fresh id (Printf.sprintf "((%s))" id) in + rtab_add t.blocks id v; + v) + | None -> block_ref_link_fresh id (Printf.sprintf "((%s))" id) + +let block_ref_link_range s start end_ = + let id_off = start + 2 in + let id_len = end_ - start - 4 in + match !current_intern with + | Some t -> ( + match rtab_find t.blocks s id_off id_len with + | Some v -> v + | None -> + let id = String.sub s id_off id_len in + let full_text = String.sub s start (end_ - start) in + let v = block_ref_link_fresh id full_text in + rtab_add t.blocks id v; + v) + | None -> + let id = String.sub s id_off id_len in + let full_text = String.sub s start (end_ - start) in + block_ref_link_fresh id full_text + +let tag_of_name name = + match !current_intern with + | Some t -> ( + match rtab_find t.tags name 0 (String.length name) with + | Some v -> v + | None -> + let v = Inline.Tag [ Inline.Plain name ] in + rtab_add t.tags name v; + v) + | None -> Inline.Tag [ Inline.Plain name ] + +let tag_of_range s off len = + match !current_intern with + | Some t -> ( + match rtab_find t.tags s off len with + | Some v -> v + | None -> + let name = String.sub s off len in + let v = Inline.Tag [ Inline.Plain name ] in + rtab_add t.tags name v; + v) + | None -> Inline.Tag [ Inline.Plain (String.sub s off len) ] + let strip_tag_trail raw = - let rec strip t = - let len = String.length t in - if len = 0 then - t - else if tag_trail t.[len - 1] then - strip (String.sub t 0 (len - 1)) - else - t - in - strip raw + let n = String.length raw in + let j = ref n in + while !j > 0 && tag_trail raw.[!j - 1] do + decr j + done; + if !j = n then + raw + else if !j = 0 then + "" + else + String.sub raw 0 !j let rec build_nested_link s start end_ depth = if depth > max_nested_ref_depth then @@ -223,25 +386,26 @@ let try_fast_scan_range s off len = if !has_bracket then complex := true else - let name = strip_tag_trail (String.sub s start (!j - start)) in - if name <> "" then - acc := (Inline.Tag [ Inline.Plain name ], None) :: !acc; + let k = ref !j in + while !k > start && tag_trail s.[!k - 1] do + decr k + done; + if !k > start then acc := (tag_of_range s start (!k - start), None) :: !acc; i := !j | '[' when !i + 1 < end_ && s.[!i + 1] = '[' -> ( match find_page_ref_end s !i with | Some (e, inner) when e <= end_ -> - let name = String.sub s (!i + 2) (e - !i - 4) in if inner then ( match build_nested_link s !i e 0 with | Some nl when List.length nl.children > 1 -> acc := (Inline.Nested_link nl, None) :: !acc; i := e | Some _ -> - acc := (page_ref_link name, None) :: !acc; + acc := (page_ref_link_range s !i e, None) :: !acc; i := e | None -> complex := true ) else ( - acc := (page_ref_link name, None) :: !acc; + acc := (page_ref_link_range s !i e, None) :: !acc; i := e ) | _ -> complex := true) @@ -249,8 +413,7 @@ let try_fast_scan_range s off len = | '(' when !i + 1 < end_ && s.[!i + 1] = '(' -> ( match find_block_ref_end s !i with | Some e when e <= end_ -> - let id = String.sub s (!i + 2) (e - !i - 4) in - acc := (block_ref_link id, None) :: !acc; + acc := (block_ref_link_range s !i e, None) :: !acc; i := e | _ -> incr i) | _ -> incr i @@ -258,7 +421,14 @@ let try_fast_scan_range s off len = if !complex then None else - Some (List.rev !acc) + match (!current_intern, !acc) with + | Some t, [ (q, None); (p, None) ] -> ( + try Some (Hashtbl.find t.titles (p, q)) with + | Not_found -> + let result = [ (p, None); (q, None) ] in + Hashtbl.add t.titles (p, q) result; + Some result) + | _ -> Some (List.rev !acc) let try_fast_scan s = try_fast_scan_range s 0 (String.length s)