///|
pub fn MarkdownEditorSnapshot::parse(source : String) -> MarkdownEditorSnapshot {
MarkdownEditorSnapshot::parse_with_font(source, @core.FontSpec::new())
}
///|
pub fn MarkdownEditorSnapshot::parse_with_font(
source : String,
base : @core.FontSpec,
base_dir? : String? = None,
) -> MarkdownEditorSnapshot {
let blocks = parse_markdown_editor_blocks(source)
let word_count = markdown_editor_word_count(source)
let metadata = markdown_editor_front_matter_metadata(blocks)
{
source,
blocks,
rich_text: markdown_editor_blocks_to_rich_text(
source,
blocks,
base,
base_dir~,
),
line_count: markdown_editor_line_count(source),
word_count,
reading_minutes: markdown_editor_reading_minutes(word_count),
active_heading: markdown_editor_active_heading(blocks),
metadata_title: metadata.title,
metadata_tags: metadata.tags,
outline: markdown_editor_outline(blocks),
}
}
///|
pub fn markdown_editor_format(
source : String,
base? : @core.FontSpec = @core.FontSpec::new(),
base_dir? : String? = None,
) -> RichTextDocument {
MarkdownEditorSnapshot::parse_with_font(source, base, base_dir~).rich_text
}
///|
pub fn markdown_editor_format_for_selection(
source : String,
selection : MarkdownEditorSelection,
base? : @core.FontSpec = @core.FontSpec::new(),
base_dir? : String? = None,
) -> RichTextDocument {
let document = markdown_editor_format(source, base~, base_dir~)
let blocks : Array[RichTextBlock] = []
for block in document.blocks {
// One decoded window per block, shared by every run; see
// `markdown_editor_block_source_chars_near`.
let chars = match block.source_range {
Some(source_range) if block.runs.length() > 0 =>
Some(
markdown_editor_block_source_chars_near(
source,
source_range.start,
source_range.end,
),
)
_ => None
}
let runs : Array[RichTextRun] = []
for run in block.runs {
runs.push(
match chars {
Some(chars) =>
markdown_editor_reveal_active_inline_run(
source,
chars~,
selection,
run,
)
None => run
},
)
}
blocks.push(
markdown_editor_reveal_active_block_marker(source, selection, {
..block,
runs,
}),
)
}
markdown_editor_collapse_inactive_blank_gaps(
RichTextDocument::new(blocks~),
selection,
base,
)
}
///|
/// Fraction of the body font size a blank source line contributes when it is
/// the paragraph separator rather than a writing line. Typora separates
/// sibling paragraphs with a collapsed `p { margin: 0.8em 0 }` (measured in
/// its stock Github theme: 12.8px at a 16px root). A paragraph already
/// contributes `inset.bottom` (0.4em, see `markdown_block_to_rich_text`); the
/// separator supplies the other 0.4em, which makes one blank line between
/// paragraphs read as Typora's 0.8em gap.
fn markdown_editor_blank_gap_line_height_scale() -> Double {
0.4
}
///|
/// Whether the blank line at `offset` renders as a writing line rather than as
/// the paragraph separator. Typora spends one blank line on every paragraph
/// break and one more on each empty paragraph it keeps, so a run alternates:
/// the first blank line is a bare break with no node at all, the second is an
/// empty line the caret can sit on, the third a break again, and so on. Its
/// stock Github theme measures 2.34em from paragraph to paragraph for one
/// blank line and 4.72em for three, which is exactly this alternation.
fn markdown_editor_blank_gap_is_writing_line(
offset : Int,
run_start : Int,
) -> Bool {
(offset - run_start) % 2 == 1
}
///|
/// Exclusive end index of the run of consecutive blank gap blocks starting
/// at `start`.
fn markdown_editor_blank_gap_run_end(
blocks : Array[RichTextBlock],
start : Int,
) -> Int {
let mut end = start
while end < blocks.length() && rich_text_block_is_blank_gap(blocks[end]) {
end = end + 1
}
end
}
///|
/// Blank lines alternate between paragraph separator and writing line: a
/// separator carries the paragraph margin (0.4em, which with the paragraph's
/// own inset is Typora's collapsed `p { margin: 0.8em 0 }`) and no line box,
/// so it reads as plain spacing and nothing can park a caret in it, while a
/// writing line is a full empty line box plus the spacing that follows it and
/// is a caret target like any other line. A run therefore shows one empty line
/// for every two blank source lines, the way Typora's paragraph model spends
/// one blank line on the break and one more on the empty paragraph.
///
/// A document with no content at all stays a plain writing surface: its blank
/// lines are all writing lines, so an empty editor still has a caret line.
fn markdown_editor_collapse_inactive_blank_gaps(
document : RichTextDocument,
selection : MarkdownEditorSelection,
base_font : @core.FontSpec,
) -> RichTextDocument {
let margin = base_font.size * markdown_editor_blank_gap_line_height_scale()
let source = document.blocks
// A document whose blocks are all blank lines is a plain writing surface,
// so every one of them stays a writing line and the caret always has a line.
let has_content = source.any(block => !rich_text_block_is_blank_gap(block))
let blocks : Array[RichTextBlock] = []
let mut index = 0
while index < source.length() {
if !rich_text_block_is_blank_gap(source[index]) {
blocks.push(source[index])
index = index + 1
continue
}
let run_end = markdown_editor_blank_gap_run_end(source, index)
for offset = index; offset < run_end; offset = offset + 1 {
let gap = source[offset]
let writing_line = !has_content ||
markdown_editor_blank_gap_is_writing_line(offset, index) ||
markdown_editor_gap_selection_active(gap, selection)
if writing_line {
// A full empty line box plus the paragraph spacing that follows it,
// so the caret can sit and type on it.
blocks.push({
..gap,
inset: {
top: 0.0,
right: gap.inset.right,
bottom: margin,
left: gap.inset.left,
},
})
} else {
// The paragraph separator: the run's margin with no line box at all.
blocks.push({
..gap,
line_height: Some(0.0),
inset: {
top: margin,
right: gap.inset.right,
bottom: 0.0,
left: gap.inset.left,
},
})
}
}
index = run_end
}
RichTextDocument::new(blocks~)
}
///|
/// The gap is the active writing line when the caret sits inside it or the
/// selection reaches into it (a selection crossing the gap keeps its full
/// height so the highlighted band stays visible).
fn markdown_editor_gap_selection_active(
block : RichTextBlock,
selection : MarkdownEditorSelection,
) -> Bool {
match block.source_range {
Some(range) => {
if selection.caret >= range.start && selection.caret <= range.end {
return true
}
match selection.selection {
Some(range_selection) => {
let normalized = range_selection.normalized()
normalized.start <= range.end && normalized.end >= range.start
}
None => false
}
}
None => false
}
}
///|
pub fn markdown_editor_format_for_writing_mode(
source : String,
selection : MarkdownEditorSelection,
base? : @core.FontSpec = @core.FontSpec::new(),
focus_mode~ : Bool,
base_dir? : String? = None,
) -> RichTextDocument {
let document = markdown_editor_format_for_selection(
source,
selection,
base~,
base_dir~,
)
if focus_mode {
markdown_editor_dim_inactive_blocks(document, selection)
} else {
document
}
}
///|
fn markdown_editor_format_source_mode(
source : String,
base : @core.FontSpec,
) -> RichTextDocument {
let length = markdown_editor_text_length(source)
let font = {
..base,
families: @core.FontFamilyStack::monospace(),
size: base.size * 0.92,
}
RichTextDocument::new(blocks=[
RichTextBlock::new(
runs=[
RichTextRun::new(
text=source,
font=Some(font),
source_range=Some({ start: 0, end: length, }),
),
],
font=Some(font),
source_range=Some({ start: 0, end: length, }),
content_range=Some({ start: 0, end: length, }),
line_height=Some(base.size * 1.42),
),
])
}
///|
fn markdown_editor_dim_inactive_blocks(
document : RichTextDocument,
selection : MarkdownEditorSelection,
) -> RichTextDocument {
let blocks : Array[RichTextBlock] = []
for block in document.blocks {
if markdown_editor_focus_block_active(block, selection) {
blocks.push(block)
} else {
blocks.push(markdown_editor_dim_rich_text_block(block))
}
}
RichTextDocument::new(blocks~)
}
///|
fn markdown_editor_focus_block_active(
block : RichTextBlock,
selection : MarkdownEditorSelection,
) -> Bool {
match block.source_range {
Some(range) => markdown_editor_selection_touches_range(selection, range)
None => true
}
}
///|
fn markdown_editor_dim_rich_text_block(block : RichTextBlock) -> RichTextBlock {
let runs : Array[RichTextRun] = []
for run in block.runs {
runs.push(markdown_editor_dim_rich_text_run(run))
}
{
..block,
runs,
color: Some(
block.color
.unwrap_or(markdown_editor_default_text_color())
.multiply_alpha(0.38),
),
border_left: markdown_editor_dim_border(block.border_left),
}
}
///|
fn markdown_editor_dim_rich_text_run(run : RichTextRun) -> RichTextRun {
{
..run,
color: match run.color {
Some(color) => Some(color.multiply_alpha(0.38))
None => None
},
}
}
///|
fn markdown_editor_dim_border(
border : @core.BorderStyle?,
) -> @core.BorderStyle? {
match border {
Some(border) =>
Some({ ..border, brush: border.brush.multiply_alpha(0.38), })
None => None
}
}
///|
fn markdown_editor_default_text_color() -> @core.Color {
@core.Color::rgba(r=0.12, g=0.12, b=0.12)
}
///|
fn markdown_editor_reveal_active_block_marker(
source : String,
selection : MarkdownEditorSelection,
block : RichTextBlock,
) -> RichTextBlock {
match (block.source_range, block.content_range) {
(Some(source_range), Some(content_range)) => {
if markdown_editor_active_code_block(source, selection, block) {
return markdown_editor_reveal_active_code_block(source, block)
}
if source_range.start >= content_range.start ||
block.runs.length() == 0 ||
block.runs[0].source_range is None ||
!markdown_editor_selection_touches_range(selection, content_range) {
return block
}
let prefix = markdown_editor_substring(
source,
source_range.start,
content_range.start,
)
if block.prefix != "" {
// Revealing the editable marker text replaces the projected lane, so
// the marker has to go with it: a task item shows its raw `- [x] `.
return { ..block, prefix, marker: None, }
}
let runs = block.runs.copy()
let first = runs[0]
runs[0] = {
..first,
text: prefix + first.text,
visual_text: None,
source_range: match first.source_range {
Some(run_range) =>
Some({ start: source_range.start, end: run_range.end, })
None => Some({ start: source_range.start, end: content_range.start, })
},
}
{ ..block, prefix: "", runs, }
}
_ => block
}
}
///|
fn markdown_editor_active_code_block(
source : String,
selection : MarkdownEditorSelection,
block : RichTextBlock,
) -> Bool {
match (block.source_range, block.content_range) {
(Some(source_range), Some(_content_range)) => {
let block_source = markdown_editor_substring(
source,
source_range.start,
source_range.end,
)
markdown_editor_source_starts_with_code_fence(block_source) &&
markdown_editor_selection_touches_range(selection, source_range)
}
_ => false
}
}
///|
/// How many visual lines the active raw source adds or removes compared with
/// the inactive projection. Inactive fenced code blocks hide the opening and
/// closing fence lines (and replace the opening line with the language label
/// when one is present), while the active form paints the raw source verbatim.
fn markdown_editor_code_block_active_line_delta(
source_text : String,
block : RichTextBlock,
) -> Int {
rich_text_text_newline_count(source_text) +
1 -
rich_text_block_visual_line_count(block)
}
///|
/// Inactive fenced code blocks reserve the height of the fence lines that
/// become visible on activation. The reservation lives in the bottom inset so
/// the inactive line boxes keep their normal code spacing; the active reveal
/// removes it before painting the raw source.
fn markdown_editor_reserve_code_block_active_height(
source_text : String,
block : RichTextBlock,
) -> RichTextBlock {
let extra_lines = markdown_editor_code_block_active_line_delta(
source_text, block,
)
if extra_lines <= 0 {
return block
}
let line_step = block.line_height.unwrap_or(0.0)
{
..block,
inset: {
..block.inset,
bottom: block.inset.bottom + extra_lines.to_double() * line_step,
},
}
}
///|
fn markdown_editor_reveal_active_code_block(
source : String,
block : RichTextBlock,
) -> RichTextBlock {
match block.source_range {
Some(source_range) => {
let source_text = markdown_editor_substring(
source,
source_range.start,
source_range.end,
)
let font = markdown_editor_code_run_font(block)
let line_delta = markdown_editor_code_block_active_line_delta(
source_text, block,
)
let line_step = block.line_height.unwrap_or(0.0)
let inset = if line_delta > 0 {
{
..block.inset,
bottom: max_double(
0.0,
block.inset.bottom - line_delta.to_double() * line_step,
),
}
} else if line_delta < 0 {
{
..block.inset,
bottom: block.inset.bottom + (-line_delta).to_double() * line_step,
}
} else {
block.inset
}
{
..block,
runs: [
RichTextRun::new(
text=source_text,
font~,
source_range=Some(source_range),
),
],
inset,
content_range: Some(source_range),
}
}
None => block
}
}
///|
/// Font for the active code block's raw source. The rendered projection
/// leads with the language-label run, whose smaller bold face is not the
/// face the code itself draws with; taking the last run keeps the raw
/// source on the code font so both states shape and wrap identically and
/// the fence-line height reservation stays exact.
fn markdown_editor_code_run_font(block : RichTextBlock) -> @core.FontSpec? {
if block.runs.length() == 0 {
return None
}
match block.runs[block.runs.length() - 1].font {
Some(font) => Some(font)
None => block.runs[0].font
}
}
///|
fn markdown_editor_source_starts_with_code_fence(source : String) -> Bool {
let chars = source.trim().to_owned().to_array()
if chars.length() < 3 {
return false
}
(chars[0] == '`' && chars[1] == '`' && chars[2] == '`') ||
(chars[0] == '~' && chars[1] == '~' && chars[2] == '~')
}
///|
/// A char-indexed window over the source for the inline marker probes.
/// The probes only ever read a handful of characters around one inline
/// span, so the reveal path builds a block-sized window; materializing the
/// whole document as a char array per touched run (the old
/// `source.to_array()`) cost O(document) per run per frame. Reads outside
/// the window return None, which fails marker detection exactly like the
/// block-adjacent newlines and blank lines the whole-source scan used to
/// find there.
priv struct MarkdownSourceChars {
chars : Array[Char]
offset : Int
}
///|
fn MarkdownSourceChars::get(self : MarkdownSourceChars, index : Int) -> Char? {
let offset = index - self.offset
if offset < 0 || offset >= self.chars.length() {
None
} else {
Some(self.chars[offset])
}
}
///|
fn MarkdownSourceChars::length(self : MarkdownSourceChars) -> Int {
self.offset + self.chars.length()
}
///|
/// Slice `[start, end)` (absolute char indices) inside the window; None
/// when either edge falls outside it.
fn MarkdownSourceChars::slice(
self : MarkdownSourceChars,
start : Int,
end : Int,
) -> Array[Char]? {
let lo = start - self.offset
let hi = end - self.offset
if start < self.offset || hi > self.chars.length() || lo > hi {
None
} else {
Some(self.chars[lo:hi].to_owned())
}
}
///|
fn markdown_editor_source_chars_all(source : String) -> MarkdownSourceChars {
{ chars: source.to_array(), offset: 0, }
}
///|
/// The char window a block's inline probes read: two characters of lookback
/// (the `**`/`[`/`!` markers) plus the rest of the block and a small tail for
/// the `]`/`)`/label reads and end-of-line scans.
///
/// It is anchored on the block so a pass over every run can decode it once.
/// The earlier per-span version sliced from `range.start - 2` to the block
/// end, so building it once per run cost the whole block every time. A
/// soft-wrapped paragraph carries one run per source line, which made
/// revealing a selection quadratic in the block size — a novel with no blank
/// lines is a single block of ~5.9M characters and ~157k runs. Anchoring on
/// the block only widens the lookback, and every probe sits at or after
/// `range.start - 2`, so the results are unchanged.
fn markdown_editor_block_source_chars_near(
source : String,
block_source_start : Int,
block_source_end : Int,
) -> MarkdownSourceChars {
let doc_length = markdown_editor_text_length(source)
let start = markdown_editor_clamp_int(block_source_start - 2, 0, doc_length)
let end = markdown_editor_clamp_int(block_source_end + 8, start, doc_length)
{
chars: markdown_editor_substring(source, start, end).to_array(),
offset: start,
}
}
///|
fn markdown_editor_window_has_marker(
chars : MarkdownSourceChars,
start : Int,
marker : String,
) -> Bool {
if start < chars.offset {
return false
}
let mut offset = start - chars.offset
for ch in marker {
match chars.chars.get(offset) {
Some(window_char) => if window_char != ch { return false }
None => return false
}
offset = offset + 1
}
true
}
///|
fn markdown_editor_single_inline_marker_at_window(
chars : MarkdownSourceChars,
index : Int,
marker : Char,
) -> Bool {
let ch = match chars.get(index) {
Some(ch) => ch
None => return false
}
if ch != marker {
return false
}
let previous_differs = match chars.get(index - 1) {
Some(previous) => previous != marker
// The near window always covers `range.start - 2`, so an out-of-window
// read here only happens at the document start (index 0).
None => true
}
let next_differs = match chars.get(index + 1) {
Some(next) => next != marker
None => true
}
previous_differs && next_differs
}
///|
fn markdown_editor_reveal_active_inline_run(
source : String,
chars~ : MarkdownSourceChars,
selection : MarkdownEditorSelection,
run : RichTextRun,
) -> RichTextRun {
match run.source_range {
Some(range) =>
// Probe the whole construct (markers and, for links/images, the target
// `(...)` too) once. The reveal anchor is that full range — not the
// run's visible span — so a caret anywhere inside the construct,
// including while the target is being typed, keeps the raw source in
// view. `None` means the run is not part of a marker span and has
// nothing to reveal. The window is the block's, shared by every run.
match markdown_editor_inline_marker_source_range(source, chars~, range) {
Some(full_range) =>
if markdown_editor_reveal_anchor_active(selection, full_range) {
{
..run,
text: markdown_editor_substring(
source,
full_range.start,
full_range.end,
),
visual_text: None,
// Drop the image binding while the raw source is shown: the
// painter keys image-vs-text purely off `image_source`, so
// leaving it set would keep drawing the picture (or its empty
// `()` placeholder) instead of the revealed Markdown text.
image_source: None,
image_size: None,
image_width_fraction: None,
source_range: Some(full_range),
}
} else {
run
}
None => run
}
None => run
}
}
///|
/// Whether the caret/selection sits *inside* a full inline construct span
/// `[start, end)` — delimiters and link/image target included — so the raw
/// Markdown should be shown. Unlike `markdown_editor_selection_touches_range`,
/// the collapsed-caret bounds are exclusive: a caret resting just before the
/// opening `!`/`[`/`**` or just after the closing `)`/`]`/`**` is outside the
/// span and keeps the formatted view, so parking the caret against an image's
/// opening marker does not flip it back to source.
fn markdown_editor_reveal_anchor_active(
selection : MarkdownEditorSelection,
full_range : RichTextSourceRange,
) -> Bool {
match markdown_editor_active_selection(selection.selection) {
Some(active) => {
let active = active.normalized()
active.start < full_range.end && active.end > full_range.start
}
None =>
selection.caret > full_range.start && selection.caret < full_range.end
}
}
///|
fn markdown_editor_selection_touches_range(
selection : MarkdownEditorSelection,
range : RichTextSourceRange,
) -> Bool {
match markdown_editor_active_selection(selection.selection) {
Some(active) => {
let active = active.normalized()
active.start < range.end && active.end > range.start
}
None => selection.caret >= range.start && selection.caret <= range.end
}
}
///|
fn markdown_editor_inline_marker_source_range(
source : String,
chars~ : MarkdownSourceChars,
range : RichTextSourceRange,
) -> RichTextSourceRange? {
if range.start < 0 || range.end < range.start || range.end > chars.length() {
return None
}
if markdown_editor_window_has_marker(chars, range.start - 2, "**") &&
markdown_editor_window_has_marker(chars, range.end, "**") {
return Some({ start: range.start - 2, end: range.end + 2, })
}
if markdown_editor_window_has_marker(chars, range.start - 2, "__") &&
markdown_editor_window_has_marker(chars, range.end, "__") {
return Some({ start: range.start - 2, end: range.end + 2, })
}
if markdown_editor_window_has_marker(chars, range.start - 2, "~~") &&
markdown_editor_window_has_marker(chars, range.end, "~~") {
return Some({ start: range.start - 2, end: range.end + 2, })
}
if markdown_editor_window_has_marker(chars, range.start - 2, "==") &&
markdown_editor_window_has_marker(chars, range.end, "==") {
return Some({ start: range.start - 2, end: range.end + 2, })
}
if markdown_editor_window_has_marker(chars, range.start - 1, "`") &&
markdown_editor_window_has_marker(chars, range.end, "`") {
return Some({ start: range.start - 1, end: range.end + 1, })
}
if markdown_editor_single_inline_marker_at_window(chars, range.start - 1, '*') &&
markdown_editor_single_inline_marker_at_window(chars, range.end, '*') {
return Some({ start: range.start - 1, end: range.end + 1, })
}
if markdown_editor_single_inline_marker_at_window(chars, range.start - 1, '_') &&
markdown_editor_single_inline_marker_at_window(chars, range.end, '_') {
return Some({ start: range.start - 1, end: range.end + 1, })
}
match markdown_editor_reference_source_range(source, chars~, range) {
Some(reference_range) => return Some(reference_range)
None => ()
}
None
}
///|
fn markdown_editor_reference_source_range(
source : String,
chars~ : MarkdownSourceChars,
range : RichTextSourceRange,
) -> RichTextSourceRange? {
let text_range = @core.TextRange::new(start=range.start, end=range.end)
match markdown_editor_range_link_bounds_in(source, chars~, text_range) {
Some(bounds) =>
return Some({ start: bounds.open_start, end: bounds.target_end + 1, })
None => ()
}
match markdown_editor_range_image_bounds_in(source, chars~, text_range) {
Some(bounds) =>
Some({ start: bounds.open_start, end: bounds.target_end + 1, })
None => None
}
}
///|
fn parse_markdown_editor_blocks(source : String) -> Array[MarkdownEditorBlock] {
parse_markdown_editor_blocks_cached(source)
}
///|
fn parse_markdown_editor_blocks_uncached(
source : String,
) -> Array[MarkdownEditorBlock] {
parse_markdown_editor_blocks_with_definitions(source, definitions=None)
}
///|
/// Index of the shell block whose `source_range` contains the char
/// `offset`, by binary search. The shell parse emits ordered,
/// non-overlapping ranges that cover the document (a block's `end` is its
/// last line's newline position and the next block starts one past it), so
/// the containing block is unique and a keystroke's block-context lookups
/// never need to scan every block. `None` when no range contains `offset`.
fn markdown_editor_block_index_at_offset(
blocks : Array[MarkdownEditorBlock],
offset : Int,
) -> Int? {
let mut low = 0
let mut high = blocks.length() - 1
while low <= high {
let mid = (low + high) / 2
let block = blocks[mid]
if offset < block.source_range.start {
high = mid - 1
} else if offset > block.source_range.end {
low = mid + 1
} else {
return Some(mid)
}
}
None
}
///|
/// Shell parse: the same line scan and block boundaries as the full parse,
/// but content blocks (paragraphs, headings, list items, quotes) carry no
/// inline runs and best-effort marker-stripped text. Used for session
/// construction so opening a document costs one linear scan instead of one
/// markdown parse per block; blocks are re-parsed on demand by
/// `markdown_editor_block_materialized` the first time their inline runs
/// are actually needed (rendering, caret geometry, export).
fn parse_markdown_editor_blocks_shell(
source : String,
) -> Array[MarkdownEditorBlock] {
parse_markdown_editor_blocks_with_definitions(
source,
// Shell blocks never consult reference definitions, so passing an empty
// set also skips the whole-document definition parse.
definitions=Some([]),
parse_inlines=false,
)
}
///|
/// Parse with a precomputed whole-source reference-definition set. The
/// session's incremental apply path passes definitions from the previous
/// source (edits that could change the definition set fall back to the
/// full parse), which skips the second whole-document markdown parse
/// that only exists to collect `[label]: url` entries.
fn parse_markdown_editor_blocks_with_definitions(
source : String,
definitions~ : Array[MarkdownReferenceDefinition]?,
parse_inlines? : Bool = true,
) -> Array[MarkdownEditorBlock] {
let blocks : Array[MarkdownEditorBlock] = []
if source == "" {
return blocks
}
let definitions = match definitions {
Some(definitions) => definitions
None => markdown_reference_definitions_cached(source)
}
let lines = markdown_editor_lines(source)
match markdown_editor_front_matter_block_at(lines) {
Some(front_matter) => {
blocks.push(front_matter.block)
let mut index = front_matter.next_index
while index < lines.length() &&
markdown_editor_line_text_is_blank(lines[index].text) {
blocks.push(markdown_editor_blank_line_block(lines[index]))
index = index + 1
}
if index < lines.length() {
let rest_start = lines[index].start
let rest = markdown_editor_substring(
source,
rest_start,
markdown_editor_text_length(source),
)
for
block in parse_markdown_editor_blocks_with_definitions(
rest,
definitions=Some(definitions),
parse_inlines~,
) {
blocks.push({
..block,
source_range: {
start: block.source_range.start + rest_start,
end: block.source_range.end + rest_start,
},
content_range: {
start: block.content_range.start + rest_start,
end: block.content_range.end + rest_start,
},
})
}
}
return blocks
}
None => ()
}
let code_lines : Array[String] = []
let mut code_start = 0
let mut code_content_start = 0
let mut code_fence = ""
let mut code_language : String? = None
let mut in_code = false
let mut index = 0
while index < lines.length() {
let line = lines[index]
if in_code {
if markdown_editor_closing_code_fence(line.text, code_fence) {
let text = markdown_join_lines(code_lines)
blocks.push({
block: markdown_editor_code_block(text, code_language),
source_range: { start: code_start, end: line.end, },
content_range: { start: code_content_start, end: line.start, },
})
code_lines.clear()
code_fence = ""
code_language = None
in_code = false
} else {
code_lines.push(line.text.to_owned())
}
} else if markdown_editor_line_text_is_blank(line.text) {
// Each blank line stays its own editable gap block (one per line, so
// every blank line is individually caret-addressable); the visual
// narrowing comes from the inactive-gap collapse at render time, and
// vertical navigation crosses consecutive gap blocks.
blocks.push(markdown_editor_blank_line_block(line))
} else {
let fence_info = if parse_inlines ||
markdown_editor_shell_line_may_open_fence(line.text) {
markdown_editor_opening_code_fence_marker(line.text)
} else {
None
}
match fence_info {
Some(fence) => {
in_code = true
code_fence = fence.marker
code_language = fence.info
code_start = line.start
code_content_start = line.end + 1
}
None => {
let html_hit = if parse_inlines ||
markdown_editor_shell_line_may_open_html(line.text) {
markdown_editor_html_block_at(lines, index)
} else {
None
}
match html_hit {
Some(html) => {
blocks.push(html.block)
index = html.end_index
}
None =>
match markdown_editor_table_block_at(lines, index) {
Some(table) => {
blocks.push(table.block)
index = table.end_index
}
None =>
match
markdown_editor_setext_heading_block_at(
lines,
index,
definitions,
parse_inlines~,
) {
Some(heading) => {
blocks.push(heading.block)
index = heading.end_index
}
None => {
let code_hit = if parse_inlines ||
markdown_editor_shell_line_is_indented(line.text) {
markdown_editor_indented_code_block_at(lines, index)
} else {
None
}
match code_hit {
Some(code) => {
blocks.push(code.block)
index = code.end_index
}
None =>
match
markdown_editor_soft_wrapped_paragraph_block_at(
lines,
index,
definitions,
parse_inlines~,
) {
Some(paragraph) => {
blocks.push(paragraph.block)
index = paragraph.end_index
}
None =>
if parse_inlines {
let parsed = parse_markdown(
line.text.to_owned(),
)
for block in parsed {
let block = markdown_editor_block_with_references(
block, definitions,
)
blocks.push({
block,
source_range: {
start: line.start,
end: line.end,
},
content_range: markdown_editor_content_range(
line,
block.kind,
),
})
}
} else {
// Shell mode: classify the line with cheap
// prefix rules instead of running the
// markdown parser per line. Shell kinds are
// exact for common lines and the real parse
// replaces the shell the first time its
// inline runs are needed.
blocks.push(
markdown_editor_shell_line_block(line),
)
}
}
}
}
}
}
}
}
}
}
index = index + 1
}
if in_code {
let text = markdown_join_lines(code_lines)
let end = markdown_editor_text_length(source)
blocks.push({
block: markdown_editor_code_block(text, code_language),
source_range: { start: code_start, end, },
content_range: { start: code_content_start, end, },
})
}
blocks
}
///|
fn markdown_editor_blank_line_block(line : MarkdownLine) -> MarkdownEditorBlock {
{
block: {
kind: Paragraph,
text: "",
inlines: [],
table_alignments: [],
code_language: None,
},
source_range: { start: line.start, end: line.end, },
content_range: { start: line.start, end: line.end, },
}
}
///|
priv struct MarkdownEditorFrontMatterParseResult {
block : MarkdownEditorBlock
next_index : Int
}
///|
fn markdown_editor_front_matter_block_at(
lines : Array[MarkdownLine],
) -> MarkdownEditorFrontMatterParseResult? {
if lines.length() < 2 || lines[0].text.trim().to_owned() != "---" {
return None
}
let content : Array[String] = []
let mut index = 1
while index < lines.length() {
if lines[index].text.trim().to_owned() == "---" {
return Some({
block: {
block: markdown_block(FrontMatter, markdown_join_lines(content)),
source_range: { start: lines[0].start, end: lines[index].end, },
content_range: { start: lines[0].end + 1, end: lines[index].start, },
},
next_index: index + 1,
})
}
content.push(lines[index].text.to_owned())
index = index + 1
}
None
}
///|
struct MarkdownEditorHtmlBlockParseResult {
block : MarkdownEditorBlock
end_index : Int
} derive(Eq, Debug)
///|
pub extend MarkdownEditorHtmlBlockParseResult with Debug::{to_repr}
///|
pub extend MarkdownEditorHtmlBlockParseResult with Eq::{not_equal, equal}
///|
fn markdown_editor_html_block_at(
lines : Array[MarkdownLine],
index : Int,
) -> MarkdownEditorHtmlBlockParseResult? {
if index < 0 || index >= lines.length() {
return None
}
if markdown_html_block_comment_start(lines[index].text.to_owned()) {
return markdown_editor_html_comment_block_at(lines, index)
}
if markdown_html_block_processing_instruction_start(
lines[index].text.to_owned(),
) {
return markdown_editor_html_processing_instruction_block_at(lines, index)
}
if markdown_html_block_cdata_start(lines[index].text.to_owned()) {
return markdown_editor_html_cdata_block_at(lines, index)
}
match markdown_html_block_start_tag(lines[index].text.to_owned()) {
Some(tag) => Some(markdown_editor_html_tag_block_at(lines, index, tag))
None => None
}
}
///|
fn markdown_editor_html_comment_block_at(
lines : Array[MarkdownLine],
index : Int,
) -> MarkdownEditorHtmlBlockParseResult? {
let html_lines : Array[String] = []
let mut next = index
while next < lines.length() {
html_lines.push(lines[next].text.to_owned())
if markdown_html_block_comment_end(lines[next].text.to_owned()) {
return Some(
markdown_editor_html_block_result(lines, html_lines, index, next),
)
}
next = next + 1
}
Some(markdown_editor_html_block_result(lines, html_lines, index, next - 1))
}
///|
fn markdown_editor_html_processing_instruction_block_at(
lines : Array[MarkdownLine],
index : Int,
) -> MarkdownEditorHtmlBlockParseResult? {
let html_lines : Array[String] = []
let mut next = index
while next < lines.length() {
html_lines.push(lines[next].text.to_owned())
if markdown_html_block_processing_instruction_end(
lines[next].text.to_owned(),
) {
return Some(
markdown_editor_html_block_result(lines, html_lines, index, next),
)
}
next = next + 1
}
Some(markdown_editor_html_block_result(lines, html_lines, index, next - 1))
}
///|
fn markdown_editor_html_cdata_block_at(
lines : Array[MarkdownLine],
index : Int,
) -> MarkdownEditorHtmlBlockParseResult? {
let html_lines : Array[String] = []
let mut next = index
while next < lines.length() {
html_lines.push(lines[next].text.to_owned())
if markdown_html_block_cdata_end(lines[next].text.to_owned()) {
return Some(
markdown_editor_html_block_result(lines, html_lines, index, next),
)
}
next = next + 1
}
Some(markdown_editor_html_block_result(lines, html_lines, index, next - 1))
}
///|
fn markdown_editor_html_tag_block_at(
lines : Array[MarkdownLine],
index : Int,
tag : String,
) -> MarkdownEditorHtmlBlockParseResult {
let container = markdown_html_block_tag_keeps_blank_lines(tag)
let html_lines : Array[String] = []
let mut depth = 0
let mut next = index
while next < lines.length() {
if next > index &&
markdown_editor_blank_line(lines[next]) &&
!(container &&
markdown_editor_html_container_closes_within(
lines,
next + 1,
tag,
depth,
64,
)) {
break
}
let text = lines[next].text.to_owned()
html_lines.push(text)
if container {
depth = depth + markdown_html_block_tag_depth_delta(text, tag)
if next == index &&
markdown_html_block_line_self_closes(lines[next].text.to_owned()) {
return markdown_editor_html_block_result(lines, html_lines, index, next)
}
if depth <= 0 && text.to_lower().contains("" + tag) {
return markdown_editor_html_block_result(lines, html_lines, index, next)
}
} else if markdown_html_block_line_closes_tag(text, tag) ||
(
next == index &&
markdown_html_block_line_self_closes(lines[next].text.to_owned())
) {
return markdown_editor_html_block_result(lines, html_lines, index, next)
}
next = next + 1
}
markdown_editor_html_block_result(lines, html_lines, index, next - 1)
}
///|
fn markdown_editor_html_container_closes_within(
lines : Array[MarkdownLine],
start : Int,
tag : String,
depth : Int,
cap : Int,
) -> Bool {
let mut depth = depth
let limit = if start + cap < lines.length() {
start + cap
} else {
lines.length()
}
for cursor in start.. MarkdownEditorHtmlBlockParseResult {
let first = lines[start_index]
let last = lines[end_index]
{
block: {
block: markdown_block(HtmlBlock, markdown_join_lines(html_lines)),
source_range: { start: first.start, end: last.end, },
content_range: { start: first.start, end: last.end, },
},
end_index,
}
}
///|
struct MarkdownEditorSetextHeadingParseResult {
block : MarkdownEditorBlock
end_index : Int
} derive(Eq, Debug)
///|
pub extend MarkdownEditorSetextHeadingParseResult with Debug::{to_repr}
///|
pub extend MarkdownEditorSetextHeadingParseResult with Eq::{not_equal, equal}
///|
fn markdown_editor_setext_heading_block_at(
lines : Array[MarkdownLine],
index : Int,
definitions : Array[MarkdownReferenceDefinition],
parse_inlines? : Bool = true,
) -> MarkdownEditorSetextHeadingParseResult? {
let heading_lines : Array[MarkdownLine] = []
let text_lines : Array[String] = []
let mut next = index
while next < lines.length() {
if heading_lines.length() > 0 {
match markdown_editor_setext_underline_level(lines[next].text) {
Some(level) => {
for heading_line in heading_lines {
text_lines.push(heading_line.text.trim().to_owned())
}
let text = markdown_join_lines(text_lines)
let first = heading_lines[0]
let last_content = heading_lines[heading_lines.length() - 1]
return Some({
block: {
block: {
kind: Heading(level),
text,
inlines: if parse_inlines {
parse_markdown_inlines_with_definitions(text, definitions)
} else {
[]
},
table_alignments: [],
code_language: None,
},
source_range: { start: first.start, end: lines[next].end, },
content_range: { start: first.start, end: last_content.end, },
},
end_index: next,
})
}
None => ()
}
}
if markdown_editor_setext_heading_content_line(lines, next, parse_inlines~) {
heading_lines.push(lines[next])
next = next + 1
} else {
return None
}
}
None
}
///|
fn markdown_editor_setext_heading_content_line(
lines : Array[MarkdownLine],
index : Int,
parse_inlines? : Bool = true,
) -> Bool {
if index < 0 || index >= lines.length() {
return false
}
let line = lines[index]
if markdown_editor_line_text_is_blank(line.text) {
return false
}
if !parse_inlines && !markdown_editor_shell_line_may_open_fence(line.text) {
// Shell mode: the per-line markdown parse and char-array fence scan
// here would dominate the open cost; the cheap marker rules agree with
// them on every common line.
if markdown_editor_shell_line_opens_html(lines, index) {
return false
}
return markdown_editor_shell_structural_kind(line.text) is None
}
if markdown_editor_opening_code_fence_marker(line.text) is Some(_) {
return false
}
if parse_inlines {
markdown_editor_is_setext_paragraph_candidate(line.text.to_owned())
} else {
markdown_editor_shell_structural_kind(line.text) is None
}
}
///|
/// Cheap pre-filter for the fence-marker check: a line can only open a
/// code fence when, within the allowed three-space indent (no tabs), its
/// first character is a backtick or tilde run. Lets the shell parse skip
/// the char-array fence scan for every plain line.
fn markdown_editor_shell_line_may_open_fence(text : StringView) -> Bool {
let length = text.length()
let mut cursor = 0
let mut spaces = 0
while cursor < length && spaces <= 3 {
let ch = text.get_char(cursor).unwrap_or(' ')
if ch == ' ' {
cursor = cursor + 1
spaces = spaces + 1
} else if ch == '\t' {
return false
} else {
break
}
}
if cursor + 2 >= length {
return false
}
let fence = text.get_char(cursor).unwrap_or(' ')
(fence == '`' || fence == '~') &&
text.get_char(cursor + 1).unwrap_or(' ') == fence &&
text.get_char(cursor + 2).unwrap_or(' ') == fence
}
///|
/// Cheap pre-filter for the HTML-block check: a line can only start an
/// HTML block when its first non-space character (within a small indent)
/// is `<`.
fn markdown_editor_shell_line_may_open_html(text : StringView) -> Bool {
let length = text.length()
let mut cursor = 0
let mut spaces = 0
while cursor < length && spaces <= 3 {
let ch = text.get_char(cursor).unwrap_or(' ')
if ch == ' ' {
cursor = cursor + 1
spaces = spaces + 1
} else if ch == '\t' {
return true
} else {
break
}
}
cursor < length && text.get_char(cursor).unwrap_or(' ') == '<'
}
///|
/// Cheap pre-filter for the indented-code check: the line must begin with
/// a space or tab.
fn markdown_editor_shell_line_is_indented(text : StringView) -> Bool {
text.length() > 0 &&
(
text.get_char(0).unwrap_or(' ') == ' ' ||
text.get_char(0).unwrap_or(' ') == '\t'
)
}
///|
/// Full HTML-block check behind the cheap `<` gate, shared by the shell
/// classifiers: a line that opens an HTML block is never paragraph
/// content, even though the marker rules cannot see it.
fn markdown_editor_shell_line_opens_html(
lines : Array[MarkdownLine],
index : Int,
) -> Bool {
let line = lines[index]
markdown_editor_shell_line_may_open_html(line.text) &&
markdown_editor_html_block_at(lines, index) is Some(_)
}
///|
/// Cheap structural-line classification for shell parses: the block kind a
/// line's marker prefix implies (heading, list item, quote, rule, footnote
/// definition), or None when the line is plain paragraph text.
/// Allocation-free single pass with O(1) UTF-16 char access — the shell
/// parse runs this once per line, and a char-array materialization per line
/// dominated the open cost on large documents. The full markdown parser
/// agrees with these rules on every common line; a shell block's text is
/// exact for headings and rules, best-effort for marker-stripped
/// list/quote content, and is always replaced by the real parse the first
/// time its inline runs are needed.
fn markdown_editor_shell_structural_kind(
text : StringView,
) -> MarkdownBlockKind? {
let length = text.length()
let mut cursor = 0
while cursor < length {
let ch = text.get_char(cursor).unwrap_or(' ')
if ch == ' ' || ch == '\t' {
cursor = cursor + 1
} else {
break
}
}
if cursor >= length {
return None
}
let first = text.get_char(cursor).unwrap_or(' ')
// ATX heading: 1-6 `#` markers, a space/tab separator, and content after
// (bare `#` / `# ` stay paragraphs, matching the real parser).
if first == '#' {
let mut index = cursor
let mut level = 0
while index < length {
let ch = text.get_char(index).unwrap_or(' ')
if ch == '#' {
level = level + 1
index = index + 1
} else {
break
}
}
if level >= 1 && level <= 6 && index < length {
let ch = text.get_char(index).unwrap_or(' ')
if ch == ' ' || ch == '\t' {
let mut content = index
while content < length {
let c = text.get_char(content).unwrap_or(' ')
if c == ' ' || c == '\t' {
content = content + 1
} else {
break
}
}
if content < length {
return Some(Heading(level))
}
}
}
return None
}
// Footnote definition: `[^label]:`
if first == '[' &&
cursor + 1 < length &&
text.get_char(cursor + 1).unwrap_or(' ') == '^' {
let mut close = cursor + 2
while close < length {
let ch = text.get_char(close).unwrap_or(' ')
if ch == ']' {
break
}
close = close + ch.utf16_len()
}
if close > cursor + 2 &&
close < length &&
close + 1 < length &&
text.get_char(close).unwrap_or(' ') == ']' &&
text.get_char(close + 1).unwrap_or(' ') == ':' {
let label = markdown_editor_shell_slice(text, start=cursor + 2, end=close)
return Some(FootnoteDefinition(label))
}
return None
}
// Task list item: `- [ ] ` / `- [x] ` / `- [X] `
if first == '-' &&
cursor + 5 < length &&
text.get_char(cursor + 1).unwrap_or(' ') == ' ' &&
text.get_char(cursor + 2).unwrap_or(' ') == '[' &&
(
text.get_char(cursor + 3).unwrap_or(' ') == ' ' ||
text.get_char(cursor + 3).unwrap_or(' ') == 'x' ||
text.get_char(cursor + 3).unwrap_or(' ') == 'X'
) &&
text.get_char(cursor + 4).unwrap_or(' ') == ']' &&
text.get_char(cursor + 5).unwrap_or(' ') == ' ' {
let checked = text.get_char(cursor + 3).unwrap_or(' ') != ' '
return Some(TaskListItem(checked))
}
// Unordered list item
if (first == '-' || first == '*' || first == '+') &&
cursor + 1 < length &&
text.get_char(cursor + 1).unwrap_or(' ') == ' ' {
return Some(UnorderedListItem)
}
// Quote
if first == '>' &&
cursor + 1 < length &&
text.get_char(cursor + 1).unwrap_or(' ') == ' ' {
return Some(Blockquote)
}
// Thematic break: three or more of the same marker, spaces allowed
// between them, nothing else on the line. `-` falls through to nothing
// else (the `- item` case returned above), so a lone `-` is paragraph
// text, matching the real parser.
if first == '-' || first == '*' || first == '_' {
let mut index = cursor
let mut count = 0
let mut structural = true
while index < length {
let ch = text.get_char(index).unwrap_or(' ')
if ch == first {
count = count + 1
index = index + 1
} else if ch == ' ' || ch == '\t' {
index = index + 1
} else {
structural = false
break
}
}
if structural && count >= 3 {
return Some(HorizontalRule)
}
return None
}
// Ordered list item: digits + `.`/`)` + space.
if first.is_ascii_digit() {
let mut index = cursor
let mut number = 0
while index < length {
let ch = text.get_char(index).unwrap_or(' ')
if ch.is_ascii_digit() {
number = number * 10 + ch.to_int() - 0x30
index = index + 1
} else {
break
}
}
if index > cursor &&
index + 1 < length &&
(
text.get_char(index).unwrap_or(' ') == '.' ||
text.get_char(index).unwrap_or(' ') == ')'
) &&
text.get_char(index + 1).unwrap_or(' ') == ' ' {
return Some(OrderedListItem(number))
}
}
None
}
///|
/// Slice `[start, end)` (UTF-16 offsets) via char access; used for the
/// rare footnote label on the shell path.
fn markdown_editor_shell_slice(
text : StringView,
start~ : Int,
end~ : Int,
) -> String {
let builder = StringBuilder()
let mut index = start
while index < end {
let ch = text.get_char(index).unwrap_or(' ')
builder.write_char(ch)
index = index + ch.utf16_len()
}
builder.to_string()
}
///|
/// Marker-stripped content text for a shell list/quote/footnote line
/// (best-effort; the real parse replaces it on materialization).
/// Allocation-light: one owned-string build from the view, no intermediate
/// char array — the shell parse runs this once per list/quote line and the
/// array round-trip dominated the per-line cost on large documents.
/// Marker prefixes are ASCII, so char indices and UTF-16 unit indices
/// coincide for `skip_marker_length`.
fn markdown_editor_shell_content_text(
text : StringView,
skip_marker_length~ : Int,
) -> String {
let length = text.length()
let mut offset = skip_marker_length
while offset < length {
let ch = text.get_char(offset).unwrap_or(' ')
if ch == ' ' || ch == '\t' {
offset = offset + 1
} else {
break
}
}
text.exact_view(start=offset, end=length).to_owned()
}
///|
fn markdown_editor_shell_line_block(line : MarkdownLine) -> MarkdownEditorBlock {
let kind = match markdown_editor_shell_structural_kind(line.text) {
Some(kind) => kind
None => Paragraph
}
let text = match kind {
Heading(_) => markdown_heading_visible_source_text(line.text)
HorizontalRule => ""
FootnoteDefinition(_) =>
markdown_editor_shell_content_text(
line.text,
skip_marker_length=markdown_editor_footnote_marker_prefix_length(
line.text,
),
)
Paragraph => line.text.to_owned()
_ =>
markdown_editor_shell_content_text(
line.text,
skip_marker_length=markdown_editor_marker_prefix_length(line.text),
)
}
{
block: {
kind,
text,
inlines: [],
table_alignments: [],
code_language: None,
},
source_range: { start: line.start, end: line.end, },
content_range: markdown_editor_content_range(line, kind),
}
}
///|
/// A shell block: a content block produced by the shell parse — no inline
/// runs yet, non-empty text. Structural blocks (code, rule, table, html,
/// front matter) and genuinely empty blocks never qualify, and a real
/// content parse of non-empty text always yields at least one inline.
fn markdown_editor_block_is_shell(editor_block : MarkdownEditorBlock) -> Bool {
let block = editor_block.block
if !block.inlines.is_empty() || block.text == "" {
return false
}
match block.kind {
Paragraph
| Heading(_)
| UnorderedListItem
| TaskListItem(_)
| OrderedListItem(_)
| Blockquote
| FootnoteDefinition(_) => true
CodeBlock | HorizontalRule | Table | HtmlBlock | FrontMatter => false
}
}
///|
/// Replace a shell block with the real parse of its source slice. Pure:
/// nothing is mutated, and `parse_markdown` memoizes per text, so repeated
/// calls for the same visible block are cache hits. Blocks the real parser
/// splits differently keep their shell form (rendered as plain source text)
/// instead of corrupting block positions.
fn markdown_editor_block_materialized(
source : String,
source_length~ : Int,
source_digest~ : UInt64,
editor_block : MarkdownEditorBlock,
) -> MarkdownEditorBlock {
if !markdown_editor_block_is_shell(editor_block) {
return editor_block
}
let range = editor_block.source_range
// The shell parse leaves a block's inline runs out, and every caret,
// hit-test and input-state query asks for the same block again. Without a
// memo each of those re-copies the block's source and re-parses it — for a
// document that is one paragraph that is a multi-megabyte copy plus a full
// markdown parse per query, which is what made clicking and scrolling a
// large document stall.
//
// Keyed on the document's content digest and the block's source range, so a
// different document (or an edited one) can never hit a stale entry. Callers
// that pass no digest opt out.
if source_digest != 0UL {
match markdown_editor_materialized_block_memo.val {
Some((digest, start, end, cached)) =>
if digest == source_digest && start == range.start && end == range.end {
return cached
}
None => ()
}
}
let materialized = markdown_editor_block_materialized_uncached(
source,
source_length~,
source_digest~,
editor_block,
)
if source_digest != 0UL {
markdown_editor_materialized_block_memo.val = Some(
(source_digest, range.start, range.end, materialized),
)
}
materialized
}
///|
/// One-entry memo for `markdown_editor_block_materialized`. A frame asks for
/// the same block from several paths, and a document whose text is one
/// paragraph makes each miss cost the whole document.
let markdown_editor_materialized_block_memo : Ref[
(UInt64, Int, Int, MarkdownEditorBlock)?,
] = Ref(None)
///|
fn markdown_editor_block_materialized_uncached(
source : String,
source_length~ : Int,
source_digest~ : UInt64,
editor_block : MarkdownEditorBlock,
) -> MarkdownEditorBlock {
if !markdown_editor_block_is_shell(editor_block) {
return editor_block
}
let range = editor_block.source_range
let slice = markdown_editor_substring(source, range.start, range.end)
match parse_markdown(slice) {
[real] => {
// Reference-style links and footnotes need the whole-source
// definition set; inline syntax does not, so most documents never
// pay for it here.
let needs_definitions = real.inlines.any(inline => {
match inline.kind {
Link => inline.target is None
FootnoteReference(_) => true
_ => false
}
})
let definitions = if needs_definitions {
markdown_reference_definitions_cached_keyed(
source,
source_length~,
source_digest~,
)
} else {
[]
}
let real = markdown_editor_block_with_references(real, definitions)
// The shell's ranges are exact (the shell scan and the real parse
// agree on block boundaries and the content-range formula), so they
// carry over unchanged.
{
block: real,
source_range: range,
content_range: editor_block.content_range,
}
}
_ => editor_block
}
}
///|
fn markdown_editor_block_with_references(
block : MarkdownBlock,
definitions : Array[MarkdownReferenceDefinition],
) -> MarkdownBlock {
match block.kind {
CodeBlock | HorizontalRule | Table | HtmlBlock | FrontMatter => block
_ =>
{
..block,
inlines: markdown_resolve_reference_inlines(block.inlines, definitions),
}
}
}
///|
struct MarkdownEditorIndentedCodeParseResult {
block : MarkdownEditorBlock
end_index : Int
} derive(Eq, Debug)
///|
pub extend MarkdownEditorIndentedCodeParseResult with Debug::{to_repr}
///|
pub extend MarkdownEditorIndentedCodeParseResult with Eq::{not_equal, equal}
///|
fn markdown_editor_indented_code_block_at(
lines : Array[MarkdownLine],
index : Int,
) -> MarkdownEditorIndentedCodeParseResult? {
if !markdown_editor_indented_code_line(lines, index) {
return None
}
let code_lines : Array[String] = []
let mut next = index
while next < lines.length() {
if markdown_editor_indented_code_line(lines, next) {
code_lines.push(
markdown_indented_code_line_content(lines[next].text.to_owned()),
)
next = next + 1
} else if markdown_editor_blank_line(lines[next]) {
match markdown_editor_next_indented_code_line_index(lines, next + 1) {
Some(code_index) =>
while next < code_index {
code_lines.push("")
next = next + 1
}
None => break
}
} else {
break
}
}
let first = lines[index]
let last = lines[next - 1]
let content_start = first.start +
markdown_indented_code_content_offset(first.text.to_owned())
Some({
block: {
block: markdown_editor_code_block(markdown_join_lines(code_lines), None),
source_range: { start: first.start, end: last.end, },
content_range: {
start: markdown_editor_min_int(content_start, last.end),
end: last.end,
},
},
end_index: next - 1,
})
}
///|
fn markdown_editor_next_indented_code_line_index(
lines : Array[MarkdownLine],
start : Int,
) -> Int? {
let mut index = start
while index < lines.length() && markdown_editor_blank_line(lines[index]) {
index = index + 1
}
if index < lines.length() && markdown_editor_indented_code_line(lines, index) {
Some(index)
} else {
None
}
}
///|
fn markdown_editor_blank_line(line : MarkdownLine) -> Bool {
markdown_editor_line_text_is_blank(line.text)
}
///|
/// Whitespace-only check without the trimmed copy the string comparison
/// used to allocate once per line per parse.
fn markdown_editor_line_text_is_blank(text : StringView) -> Bool {
for ch in text {
if ch != ' ' && ch != '\t' && ch != '\r' {
return false
}
}
true
}
///|
fn markdown_editor_indented_code_line(
lines : Array[MarkdownLine],
index : Int,
) -> Bool {
if index < 0 || index >= lines.length() {
return false
}
markdown_indented_code_content_offset(lines[index].text.to_owned()) > 0
}
///|
struct MarkdownEditorParagraphParseResult {
block : MarkdownEditorBlock
end_index : Int
} derive(Eq, Debug)
///|
pub extend MarkdownEditorParagraphParseResult with Debug::{to_repr}
///|
pub extend MarkdownEditorParagraphParseResult with Eq::{not_equal, equal}
///|
fn markdown_editor_soft_wrapped_paragraph_block_at(
lines : Array[MarkdownLine],
index : Int,
definitions : Array[MarkdownReferenceDefinition],
parse_inlines? : Bool = true,
) -> MarkdownEditorParagraphParseResult? {
if !markdown_editor_soft_wrapped_paragraph_line(lines, index, parse_inlines~) {
return None
}
let paragraph_lines : Array[MarkdownLine] = [lines[index]]
let mut next = index + 1
while next < lines.length() &&
markdown_editor_setext_heading_level_at(lines, next) is None &&
markdown_editor_soft_wrapped_paragraph_line(lines, next, parse_inlines~) {
paragraph_lines.push(lines[next])
next = next + 1
}
if paragraph_lines.length() < 2 {
return None
}
let text_lines : Array[String] = []
for line in paragraph_lines {
text_lines.push(line.text.to_owned())
}
let text = markdown_join_lines(text_lines)
Some({
block: {
block: {
kind: Paragraph,
text,
inlines: if parse_inlines {
parse_markdown_inlines_with_definitions(text, definitions)
} else {
[]
},
table_alignments: [],
code_language: None,
},
source_range: {
start: paragraph_lines[0].start,
end: paragraph_lines[paragraph_lines.length() - 1].end,
},
content_range: {
start: paragraph_lines[0].start,
end: paragraph_lines[paragraph_lines.length() - 1].end,
},
},
end_index: next - 1,
})
}
///|
/// Shell-mode paragraph classification: same shape as
/// `markdown_editor_soft_wrapped_paragraph_line` but the per-line
/// "is this plain paragraph text" question is answered by the cheap prefix
/// rules instead of running the markdown parser on every line.
fn markdown_editor_soft_wrapped_paragraph_line(
lines : Array[MarkdownLine],
index : Int,
parse_inlines? : Bool = true,
) -> Bool {
if index < 0 || index >= lines.length() {
return false
}
let line = lines[index]
if markdown_editor_line_text_is_blank(line.text) {
return false
}
if !parse_inlines {
// Shell mode: skip the char-array fence scan, the trimmed separator
// scan and the per-line markdown parse — the cheap marker rules agree
// with them on every common line.
if markdown_editor_shell_line_may_open_fence(line.text) &&
markdown_editor_opening_code_fence_marker(line.text) is Some(_) {
return false
}
if markdown_editor_shell_line_opens_html(lines, index) {
return false
}
if index + 1 < lines.length() {
if lines[index + 1].text.contains("|") &&
markdown_editor_table_block_at(lines, index) is Some(_) {
return false
}
if markdown_editor_setext_underline_level(lines[index + 1].text)
is Some(_) {
return false
}
}
return markdown_editor_shell_structural_kind(line.text) is None
}
if markdown_editor_opening_code_fence_marker(line.text) is Some(_) ||
markdown_editor_table_block_at(lines, index) is Some(_) ||
markdown_editor_setext_heading_level_at(lines, index) is Some(_) {
return false
}
match parse_markdown(line.text.to_owned()) {
[{ kind: Paragraph, .. }] => true
_ => false
}
}
///|
priv struct MarkdownEditorTableParseResult {
block : MarkdownEditorBlock
end_index : Int
}
///|
fn markdown_editor_table_block_at(
lines : Array[MarkdownLine],
index : Int,
) -> MarkdownEditorTableParseResult? {
// A separator row must contain `|`; lines whose next line has none skip
// the trimmed cell scan entirely (this check runs once per line).
if index + 1 >= lines.length() ||
!lines[index + 1].text.contains("|") ||
!markdown_editor_table_separator_line(lines[index + 1].text.to_owned()) {
return None
}
let table_lines : Array[MarkdownLine] = [lines[index], lines[index + 1]]
let mut next = index + 2
while next < lines.length() &&
markdown_editor_table_body_line(lines[next].text.to_owned()) {
table_lines.push(lines[next])
next = next + 1
}
let segment_lines : Array[String] = []
for line in table_lines {
segment_lines.push(line.text.to_owned())
}
let segment = markdown_join_lines(segment_lines)
match parse_markdown(segment) {
[{ kind: Table, .. } as block] => {
let source_start = table_lines[0].start
let source_end = table_lines[table_lines.length() - 1].end
Some({
block: {
block,
source_range: { start: source_start, end: source_end, },
content_range: { start: source_start, end: source_end, },
},
end_index: next - 1,
})
}
_ => None
}
}
///|
fn markdown_editor_table_separator_line(line : String) -> Bool {
let trimmed = line.trim().to_owned()
if !trimmed.contains("|") {
return false
}
let cells = markdown_editor_split_table_cells(trimmed)
if cells.length() == 0 {
return false
}
for cell in cells {
if !markdown_editor_table_separator_cell(cell.trim().to_owned()) {
return false
}
}
true
}
///|
fn markdown_editor_table_separator_cell(cell : String) -> Bool {
let chars = cell.to_array()
let mut dash_count = 0
for ch in chars {
if ch == '-' {
dash_count = dash_count + 1
} else if ch != ':' && ch != ' ' && ch != '\t' {
return false
}
}
dash_count >= 3
}
///|
fn markdown_editor_table_body_line(line : String) -> Bool {
let trimmed = line.trim().to_owned()
trimmed != "" && trimmed.contains("|")
}
///|
fn markdown_editor_split_table_cells(line : String) -> Array[String] {
let cells : Array[String] = []
let chars = line.to_array()
let mut start = 0
let mut end = chars.length()
if start < end && chars[start] == '|' {
start = start + 1
}
if end > start &&
chars[end - 1] == '|' &&
markdown_editor_table_pipe_is_separator(chars, end - 1) {
end = end - 1
}
let current : Array[Char] = []
let mut index = start
while index < end {
if chars[index] == '|' &&
markdown_editor_table_pipe_is_separator(chars, index) {
cells.push(String::from_array(current))
current.clear()
} else {
current.push(chars[index])
}
index = index + 1
}
cells.push(String::from_array(current))
cells
}
///|
fn markdown_editor_setext_heading_level_at(
lines : Array[MarkdownLine],
index : Int,
) -> Int? {
if index + 1 >= lines.length() {
return None
}
let text = lines[index].text.trim().to_owned()
if text == "" ||
markdown_editor_opening_code_fence_marker(lines[index].text) is Some(_) {
return None
}
// Underline first: it is a trivial character scan, and the paragraph
// candidate mini-parse below is only worth running on the rare line
// that actually sits above a `---`/`===` underline.
match markdown_editor_setext_underline_level(lines[index + 1].text) {
None => None
Some(level) =>
if markdown_editor_is_setext_paragraph_candidate(
lines[index].text.to_owned(),
) {
Some(level)
} else {
None
}
}
}
///|
fn markdown_editor_is_setext_paragraph_candidate(line : String) -> Bool {
match parse_markdown(line) {
[{ kind: Paragraph, .. }] => true
_ => false
}
}
///|
fn markdown_editor_setext_underline_level(line : StringView) -> Int? {
// Allocation-free scan: skip leading whitespace, then require a run of
// `=` (level 1) or `-` (level 2) covering the rest of the line.
let length = line.length()
let mut cursor = 0
while cursor < length {
let ch = line.get_char(cursor).unwrap_or(' ')
if ch == ' ' || ch == '\t' {
cursor = cursor + 1
} else {
break
}
}
if cursor >= length {
return None
}
let marker = line.get_char(cursor).unwrap_or(' ')
if marker != '=' && marker != '-' {
return None
}
let mut index = cursor + 1
while index < length {
let ch = line.get_char(index).unwrap_or(' ')
if ch == marker {
index = index + 1
} else if ch == ' ' || ch == '\t' {
// Trailing whitespace is allowed; nothing else may follow.
index = index + 1
while index < length {
let tail = line.get_char(index).unwrap_or(' ')
if tail == ' ' || tail == '\t' {
index = index + 1
} else {
return None
}
}
break
} else {
return None
}
}
Some(if marker == '=' { 1 } else { 2 })
}
///|
fn markdown_editor_closing_code_fence(
line : StringView,
fence : String,
) -> Bool {
let chars = line.to_array()
let fence_chars = fence.to_array()
let indent = markdown_editor_leading_indent_length(chars)
if !markdown_editor_code_fence_indent_allowed(chars, indent) ||
fence_chars.length() < 3 ||
chars.length() < indent + fence_chars.length() {
return false
}
for index in 0.. RichTextDocument {
let rich_blocks : Array[RichTextBlock] = []
for block in blocks {
rich_blocks.push(
markdown_editor_block_to_rich_text(source, block, base, base_dir~),
)
}
RichTextDocument::new(blocks=rich_blocks)
}
///|
fn markdown_editor_block_to_rich_text(
source : String,
editor_block : MarkdownEditorBlock,
base : @core.FontSpec,
base_dir? : String? = None,
) -> RichTextBlock {
markdown_editor_block_to_rich_text_with_cache(
source,
editor_block,
base,
base_dir~,
build=(source, editor_block, base, base_dir) => {
markdown_editor_block_to_rich_text_uncached(
source,
editor_block,
base,
base_dir~,
)
},
)
}
///|
fn markdown_editor_block_to_rich_text_uncached(
source : String,
editor_block : MarkdownEditorBlock,
base : @core.FontSpec,
base_dir~ : String?,
) -> RichTextBlock {
let block = markdown_block_to_rich_text(editor_block.block, base, base_dir~)
let rich_block = {
..block,
runs: markdown_editor_runs_with_source_ranges(
block.runs,
source,
editor_block,
),
table: markdown_editor_table_with_source_ranges(
block.table,
source,
editor_block,
),
source_range: Some(markdown_core_range(editor_block.source_range)),
content_range: Some(markdown_core_range(editor_block.content_range)),
}
match editor_block.block.kind {
CodeBlock => {
let source_text = markdown_editor_substring(
source,
editor_block.source_range.start,
editor_block.source_range.end,
)
if markdown_editor_source_starts_with_code_fence(source_text) {
markdown_editor_reserve_code_block_active_height(
source_text, rich_block,
)
} else {
rich_block
}
}
_ => rich_block
}
}
///|
fn markdown_editor_table_with_source_ranges(
table : RichTextTable?,
source : String,
editor_block : MarkdownEditorBlock,
) -> RichTextTable? {
match table {
Some(table) => {
let block_source_text = markdown_editor_substring(
source,
editor_block.source_range.start,
editor_block.source_range.end,
)
let source_cells = markdown_editor_table_cells_for_block(
block_source_text,
{
block: editor_block.block,
source_range: {
start: 0,
end: markdown_editor_text_length(block_source_text),
},
content_range: {
start: 0,
end: markdown_editor_text_length(block_source_text),
},
},
)
let rows : Array[Array[RichTextTableCell]] = []
for row_index in 0..
Some({
start: editor_block.source_range.start + range.content_start,
end: editor_block.source_range.start + range.content_end,
})
None => cell.source_range
}
// Cell runs carry cell-relative ranges; re-base them onto the
// document source so reveal/hit math on the absolute source works.
let previous_start : Int = match cell.source_range {
Some(previous) => previous.start
None => 0
}
let shift : Int = match next_source_range {
Some(next_range) => next_range.start - previous_start
None => 0
}
row.push({
..cell,
runs: if shift == 0 {
cell.runs
} else {
cell.runs.map(run => {
match run.source_range {
Some(range) =>
{
..run,
source_range: Some({
start: range.start + shift,
end: range.end + shift,
}),
}
None => run
}
})
},
source_range: next_source_range,
})
}
rows.push(row)
}
Some({ ..table, rows, })
}
None => None
}
}
///|
fn markdown_editor_find_table_cell(
cells : Array[MarkdownEditorTableCellRange],
row : Int,
column : Int,
) -> MarkdownEditorTableCellRange? {
for cell in cells {
if cell.row == row && cell.column == column {
return Some(cell)
}
}
None
}
///|
fn markdown_editor_runs_with_source_ranges(
runs : Array[RichTextRun],
source : String,
editor_block : MarkdownEditorBlock,
) -> Array[RichTextRun] {
let mapped : Array[RichTextRun] = []
let mut search = 0
let mut index = 0
let content = markdown_editor_run_source_content(source, editor_block)
// The block's content decoded once, shared by every run's lookup below. A
// soft-wrapped paragraph carries one run per source line, so decoding the
// content inside the per-run lookup made this pass quadratic in the block
// size — for a novel with no blank lines (one block of ~5.9M chars and
// ~157k runs) the decode alone ran for hours.
let content_chars = content.to_array()
for run in runs {
if editor_block.block.kind is CodeBlock &&
editor_block.block.code_language is Some(_) &&
index < 2 {
mapped.push(run)
} else if index == 0 && editor_block.block.kind is FootnoteDefinition(_) {
mapped.push({
..run,
source_range: Some({
start: editor_block.source_range.start,
end: editor_block.content_range.start,
}),
})
} else {
let range = markdown_editor_find_run_source_range(
content_chars,
run.text,
search,
)
mapped.push({
..run,
source_range: Some({
start: editor_block.content_range.start + range.start,
end: editor_block.content_range.start + range.end,
}),
})
search = range.end
}
index = index + 1
}
mapped
}
///|
fn markdown_editor_run_source_content(
source : String,
editor_block : MarkdownEditorBlock,
) -> String {
match editor_block.block.kind {
Heading(_)
| UnorderedListItem
| TaskListItem(_)
| OrderedListItem(_)
| Blockquote
| Paragraph
| FootnoteDefinition(_) =>
markdown_editor_substring(
source,
editor_block.content_range.start,
editor_block.content_range.end,
)
CodeBlock | HorizontalRule | Table | HtmlBlock | FrontMatter =>
markdown_editor_block_source_content(editor_block)
}
}
///|
fn markdown_editor_block_source_content(
editor_block : MarkdownEditorBlock,
) -> String {
let source = editor_block.source_range
let content = editor_block.content_range
let chars = markdown_editor_block_source_text(editor_block).to_array()
let start = markdown_editor_clamp_int(
content.start - source.start,
0,
chars.length(),
)
let end = markdown_editor_clamp_int(
content.end - source.start,
start,
chars.length(),
)
String::from_array(chars[start:end])
}
///|
fn markdown_editor_block_source_text(
editor_block : MarkdownEditorBlock,
) -> String {
let prefix_padding = markdown_editor_block_source_prefix_padding(editor_block)
match editor_block.block.kind {
Heading(level) =>
prefix_padding +
markdown_editor_heading_source(level, editor_block.block.text)
UnorderedListItem =>
prefix_padding +
"- " +
markdown_editor_inline_source(editor_block.block.inlines)
TaskListItem(checked) =>
if checked {
prefix_padding +
"- [x] " +
markdown_editor_inline_source(editor_block.block.inlines)
} else {
prefix_padding +
"- [ ] " +
markdown_editor_inline_source(editor_block.block.inlines)
}
OrderedListItem(number) =>
prefix_padding +
number.to_string() +
". " +
markdown_editor_inline_source(editor_block.block.inlines)
Blockquote =>
prefix_padding +
"> " +
markdown_editor_inline_source(editor_block.block.inlines)
Paragraph =>
prefix_padding + markdown_editor_inline_source(editor_block.block.inlines)
FootnoteDefinition(label) =>
prefix_padding +
"[^" +
label +
"]: " +
markdown_editor_inline_source(editor_block.block.inlines)
FrontMatter => "---\n" + editor_block.block.text + "\n---"
CodeBlock | HorizontalRule | Table | HtmlBlock => editor_block.block.text
}
}
///|
fn markdown_editor_block_source_prefix_padding(
editor_block : MarkdownEditorBlock,
) -> String {
let marker_length = match editor_block.block.kind {
Heading(level) => level + 1
UnorderedListItem => 2
TaskListItem(_) => 6
OrderedListItem(number) => markdown_editor_digit_count(number) + 2
Blockquote => 2
FootnoteDefinition(label) =>
markdown_editor_text_length("[^" + label + "]: ")
Paragraph | CodeBlock | HorizontalRule | Table | HtmlBlock | FrontMatter =>
0
}
let padding_length = markdown_editor_max_int(
0,
editor_block.content_range.start -
editor_block.source_range.start -
marker_length,
)
let chars : Array[Char] = []
for _ in 0.. String {
let text = ""
let parts : Array[String] = []
for inline in inlines {
match inline.kind {
Plain => parts.push(inline.text)
Bold => parts.push("**" + inline.text + "**")
Italic => parts.push("*" + inline.text + "*")
Code => parts.push("`" + inline.text + "`")
Strikethrough => parts.push("~~" + inline.text + "~~")
Highlight => parts.push("==" + inline.text + "==")
Link =>
parts.push(
"[" + inline.text + "](" + markdown_editor_inline_target(inline) + ")",
)
Autolink => parts.push("<" + markdown_editor_inline_target(inline) + ">")
Image =>
parts.push(
" +
")",
)
FootnoteReference => parts.push("[^" + inline.text + "]")
HtmlInline => parts.push(inline.text)
}
}
ignore(text)
markdown_join_lines([markdown_join_strings(parts)])
}
///|
fn markdown_editor_inline_target(inline : MarkdownInline) -> String {
match inline.target {
Some(target) => target
None => inline.text
}
}
///|
fn markdown_join_strings(parts : Array[String]) -> String {
let chars : Array[Char] = []
for part in parts {
chars.append(part.to_array())
}
String::from_array(chars)
}
///|
fn markdown_editor_heading_source(level : Int, text : String) -> String {
let marks : Array[Char] = []
for _ in 0.. MarkdownEditorRunSourceRange {
let run_chars = run_text.to_array()
if run_chars.length() == 0 {
let offset = markdown_editor_clamp_int(start, 0, content_chars.length())
return { start: offset, end: offset, }
}
let mut index = markdown_editor_clamp_int(start, 0, content_chars.length())
while index < content_chars.length() {
match
markdown_editor_match_run_source_end(content_chars, index, run_chars) {
Some(end) => return { start: index, end, }
None => index = index + 1
}
}
let offset = markdown_editor_clamp_int(start, 0, content_chars.length())
{ start: offset, end: offset + run_chars.length(), }
}
///|
fn markdown_editor_match_run_source_end(
content : Array[Char],
start : Int,
target : Array[Char],
) -> Int? {
let mut source_index = start
let mut target_index = 0
while target_index < target.length() {
if source_index >= content.length() {
return None
}
match markdown_html_entity_decode_at(content, source_index) {
Some(entity) if entity.ch == target[target_index] => {
source_index = entity.end
target_index = target_index + 1
continue
}
_ => ()
}
if content[source_index] == target[target_index] {
source_index = source_index + 1
target_index = target_index + 1
} else if content[source_index] == '\\' &&
source_index + 1 < content.length() &&
content[source_index + 1] == target[target_index] {
source_index = source_index + 2
target_index = target_index + 1
} else {
return None
}
}
Some(source_index)
}
///|
fn markdown_core_range(range : MarkdownSourceRange) -> RichTextSourceRange {
{ start: range.start, end: range.end, }
}
///|
fn markdown_editor_block_kind_label(kind : MarkdownBlockKind) -> String {
match kind {
Heading(level) => "Heading " + level.to_string()
UnorderedListItem => "Bullet list"
TaskListItem(_) => "Task list"
OrderedListItem(_) => "Ordered list"
Blockquote => "Quote"
CodeBlock => "Code block"
HorizontalRule => "Rule"
Table => "Table"
FootnoteDefinition(label) => "Footnote " + label
HtmlBlock => "HTML"
FrontMatter => "Front matter"
Paragraph => "Paragraph"
}
}
///|
fn markdown_editor_content_range(
line : MarkdownLine,
kind : MarkdownBlockKind,
) -> MarkdownSourceRange {
let offset = match kind {
Heading(_)
| UnorderedListItem
| TaskListItem(_)
| OrderedListItem(_)
| Blockquote => markdown_editor_marker_prefix_length(line.text)
FootnoteDefinition(_) =>
markdown_editor_footnote_marker_prefix_length(line.text)
Paragraph | CodeBlock | HorizontalRule | Table | HtmlBlock | FrontMatter =>
0
}
let start = markdown_editor_min_int(line.start + offset, line.end)
let end = match kind {
Heading(_) =>
markdown_editor_min_int(
line.start +
markdown_heading_source_content_end_offset(line.text, offset),
line.end,
)
_ => line.end
}
{ start, end: markdown_editor_max_int(start, end), }
}
///|
fn markdown_editor_footnote_marker_prefix_length(line : StringView) -> Int {
// Allocation-free view scan; marker prefixes are ASCII, so char indices
// and UTF-16 unit indices coincide.
let length = line.length()
let mut indent = 0
while indent < length {
let ch = line.get_char(indent).unwrap_or(' ')
if ch == ' ' || ch == '\t' {
indent = indent + 1
} else {
break
}
}
if length < indent + 5 ||
line.get_char(indent).unwrap_or(' ') != '[' ||
line.get_char(indent + 1).unwrap_or(' ') != '^' {
return 0
}
let mut close = indent + 2
while close < length && line.get_char(close).unwrap_or(' ') != ']' {
close = close + 1
}
if close <= indent + 2 ||
close + 2 >= length ||
line.get_char(close + 1).unwrap_or(' ') != ':' ||
line.get_char(close + 2).unwrap_or(' ') != ' ' {
return 0
}
close + 3
}
///|
///|
/// A copy of `editor_block` holding only source lines `[first_line, last_line]`
/// of its content.
///
/// The line window needs to materialise a slice of a block that is taller than
/// the viewport, and the block-granular range can never cull such a block — a
/// document with no blank lines collapses into one paragraph holding every
/// line, so "visible blocks" is the whole document.
///
/// Ranges stay **absolute**: the returned block's `content_range` starts at the
/// first kept line's real offset, and its runs are parsed from the slice, so
/// `markdown_editor_runs_with_source_ranges` rebases onto that offset and every
/// consumer keeps seeing true source offsets. Nothing downstream needs to know
/// the block was sliced.
///
///|
/// Block-line bounds for a slice via the document's line-start index:
/// O(log n) to locate the block's first line, then two direct lookups.
/// Returns None when the index does not line up with the block (a content
/// range that is not a line start) so the caller falls back to the scan,
/// and when the slice would cover the whole block (a no-op, matching the
/// scan path's "unchanged" contract).
fn markdown_editor_block_slice_bounds_via_index(
index : Array[Int],
content_range : MarkdownSourceRange,
first_line : Int,
last_line : Int,
source_length : Int,
) -> (Int, Int)? {
let mut low = 0
let mut high = index.length() - 1
let mut block_line = 0
while low <= high {
let mid = low + (high - low) / 2
if index[mid] <= content_range.start {
block_line = mid
low = mid + 1
} else {
high = mid - 1
}
}
if index[block_line] != content_range.start {
return None
}
let first = block_line + first_line
if first >= index.length() {
return None
}
let slice_start = index[first]
let last = block_line + last_line
let raw_end = if last + 1 < index.length() {
index[last + 1] - 1
} else {
source_length
}
let slice_end = if raw_end > content_range.end {
content_range.end
} else {
raw_end
}
if slice_end <= slice_start {
return None
}
if slice_start == content_range.start && slice_end == content_range.end {
return None
}
Some((slice_start, slice_end))
}
///|
/// Returns the block unchanged when the range covers it, when the block has no
/// content lines, or when the slice would be empty — callers treat that as
/// "no clipping" rather than having to special-case it.
fn markdown_editor_block_slice_to_source_lines(
source : String,
editor_block : MarkdownEditorBlock,
first_line : Int,
last_line : Int,
line_index? : Array[Int]? = None,
) -> MarkdownEditorBlock {
let content_start = editor_block.content_range.start
let content_end = editor_block.content_range.end
if last_line < first_line || content_end <= content_start {
return editor_block
}
// With the document's line-start index, the slice bounds are two direct
// lookups past one binary search — no walk over the block. A block that
// holds a whole novel otherwise materialized and scanned every one of
// its characters on every window build (every frame).
match line_index {
Some(index) =>
match
markdown_editor_block_slice_bounds_via_index(
index,
editor_block.content_range,
first_line,
last_line,
markdown_editor_text_length(source),
) {
Some((slice_start, slice_end)) => {
let text = markdown_editor_substring(source, slice_start, slice_end)
return {
block: {
..editor_block.block,
text,
inlines: parse_markdown_inlines_with_definitions(text, []),
},
source_range: { start: slice_start, end: slice_end, },
content_range: { start: slice_start, end: slice_end, },
}
}
None => ()
}
None => ()
}
let chars = markdown_editor_substring(source, content_start, content_end).to_array()
// Offsets of each line's first character within the slice, plus one past the
// last line's end.
let line_starts : Array[Int] = [0]
for index, ch in chars {
if ch == '\n' {
line_starts.push(index + 1)
}
}
let line_count = line_starts.length()
let first = if first_line < 0 { 0 } else { first_line }
if first >= line_count {
return editor_block
}
let last = if last_line >= line_count - 1 {
line_count - 1
} else {
last_line
}
// Only a range that covers the whole block is a no-op. A *bottom-only* cap —
// first line zero, last line bounded — is the common case for a block that
// starts inside the band, and treating it as "nothing to do" left the whole
// block materialised.
if first == 0 && last == line_count - 1 {
return editor_block
}
let slice_start = line_starts[first]
let slice_end = if last + 1 < line_count {
// The newline that ends `last` stays out of the slice, matching how the
// block's own content range excludes its trailing separator.
line_starts[last + 1] - 1
} else {
chars.length()
}
if slice_end <= slice_start {
return editor_block
}
let text = String::from_array(chars[slice_start:slice_end])
{
block: {
..editor_block.block,
text,
inlines: parse_markdown_inlines_with_definitions(text, []),
},
source_range: {
start: content_start + slice_start,
end: content_start + slice_end,
},
content_range: {
start: content_start + slice_start,
end: content_start + slice_end,
},
}
}
///|
const MARKDOWN_EDITOR_SLICE_TO_LAST_LINE : Int = 1073741824