use crate::document::ParsedDoc;
use crate::editor::state::sub_lines_in_block;
use crate::editor::EditorState;
use crate::ui::rendered_view::raw_source_lines;
#[cfg(test)]
thread_local! {
static BUILD_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
impl EditorState {
pub fn source_line_at_visual_row(&self, visual_row: usize, width: usize) -> Option<usize> {
let (rendered_idx, sub_row) = self.parsed.line_at_visual_row(visual_row, width);
if sub_row != 0 {
return None;
}
let parsed = &self.parsed;
parsed
.source_lines_or_init(|| build_source_line_map(parsed))
.get(rendered_idx)
.copied()
.flatten()
}
}
fn build_source_line_map(parsed: &ParsedDoc) -> Vec<Option<usize>> {
#[cfg(test)]
BUILD_COUNT.with(|c| c.set(c.get() + 1));
let mut map: Vec<Option<usize>> = vec![None; parsed.lines.len()];
if map.is_empty() {
return map;
}
let contents = parsed.source();
let mut scanned = 0usize;
let mut block_line = 0usize;
for block_idx in 0..parsed.source_map.block_count() {
let Some(range) = parsed.source_map.original_range_for_block(block_idx) else {
continue;
};
let start = range.start.min(contents.len());
if start > scanned {
block_line += contents.as_bytes()[scanned..start]
.iter()
.filter(|&&b| b == b'\n')
.count();
scanned = start;
}
let own = parsed.block_own_line_count(block_idx);
if own == 0 {
continue;
}
let rendered = parsed.source_map.rendered_lines_for_block(block_idx);
let source = contents
.get(start..range.end.min(contents.len()))
.unwrap_or("");
let raw_lines = raw_source_lines(source);
let last_content = raw_lines
.iter()
.rposition(|l| !l.trim().is_empty())
.unwrap_or(0);
let subs = sub_lines_in_block(parsed, start, block_idx, own, source, &raw_lines);
for (raw_line, &sub) in subs.iter().enumerate().take(last_content + 1) {
if let Some(slot) = map.get_mut(rendered.start + sub) {
*slot = Some(block_line + raw_line);
}
}
}
map
}
#[cfg(test)]
mod tests {
use crate::config::Theme;
use crate::document::Buffer;
use crate::editor::EditorState;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn state_for(source: &str, width: usize) -> EditorState {
let mut state = EditorState::new(Buffer::from_str(source), theme());
state.mode = crate::editor::Mode::Rendered;
state.set_viewport_width(width);
state.refresh_parsed();
state
}
fn builds(f: impl FnOnce()) -> usize {
let before = super::BUILD_COUNT.with(|c| c.get());
f();
super::BUILD_COUNT.with(|c| c.get()) - before
}
fn type_text(state: &mut EditorState, text: &str) {
state.apply_delta(crate::document::EditDelta {
offset: state.cursor.offset,
removed: String::new(),
inserted: text.to_owned(),
});
}
fn labels(state: &EditorState, width: usize) -> Vec<Option<usize>> {
(0..state.parsed.total_visual_rows(width))
.map(|row| state.source_line_at_visual_row(row, width))
.collect()
}
#[test]
fn plain_paragraphs_number_one_to_one() {
let state = state_for("alpha\n\nbravo\n", 80);
assert_eq!(labels(&state, 80), vec![Some(0), Some(1), Some(2), Some(3)]);
}
#[test]
fn table_rows_map_back_to_their_source_lines() {
let source = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\nafter\n";
let state = state_for(source, 80);
let labels = labels(&state, 80);
let numbered: Vec<usize> = labels.iter().flatten().copied().collect();
assert_eq!(numbered, vec![0, 1, 2, 3, 4, 5]);
assert!(labels.len() > numbered.len());
}
#[test]
fn setext_heading_numbers_both_of_its_source_lines() {
let state = state_for("Title\n-----\n\nbody\n", 80);
let labels = labels(&state, 80);
assert_eq!(labels, vec![Some(0), Some(1), Some(2), Some(3), Some(4)]);
}
#[test]
fn hidden_html_comment_claims_no_row() {
let state = state_for("Alpha.\n\n<!-- hidden -->\n\nBeta.\n", 80);
let labels = labels(&state, 80);
let numbered: Vec<usize> = labels.iter().flatten().copied().collect();
assert!(
!numbered.contains(&2),
"the comment's line must not be numbered: {labels:?}"
);
assert!(
numbered.contains(&4),
"`Beta.` must keep its own number: {labels:?}"
);
assert!(
numbered.windows(2).all(|w| w[0] < w[1]),
"numbers must stay ascending and unique: {labels:?}"
);
}
#[test]
fn suppressed_blank_lines_claim_no_row() {
let theme = theme();
let mut state = EditorState::new_with_config(
Buffer::from_str("alpha\n\n\n\nbravo\n"),
theme,
false,
true,
24,
);
state.mode = crate::editor::Mode::Rendered;
state.set_viewport_width(80);
state.refresh_parsed();
let labels = labels(&state, 80);
let numbered: Vec<usize> = labels.iter().flatten().copied().collect();
assert!(
numbered.contains(&4),
"`bravo` must keep its own number: {labels:?}"
);
assert!(
numbered.windows(2).all(|w| w[0] < w[1]),
"numbers must stay ascending and unique: {labels:?}"
);
}
#[test]
fn image_reserve_rows_are_blank_below_the_first() {
let mut state = state_for("\n\nafter\n", 80);
state.image_max_height = 6;
state.refresh_parsed();
let labels = labels(&state, 80);
assert_eq!(labels.first().copied().flatten(), Some(0));
let numbered: Vec<usize> = labels.iter().flatten().copied().collect();
assert_eq!(numbered, vec![0, 1, 2, 3]);
}
#[test]
fn wrapped_continuation_rows_are_blank() {
let source = "aaaa bbbb cccc dddd eeee ffff\n";
let state = state_for(source, 12);
let labels = labels(&state, 12);
assert!(labels.len() > 2, "expected the line to wrap");
assert_eq!(labels[0], Some(0));
assert!(labels[1..labels.len() - 1].iter().all(|l| l.is_none()));
}
#[test]
fn a_swallowed_blank_does_not_steal_the_next_line_number() {
let state = state_for("Intro.\n\n- item\n\n continuation\n\nafter\n", 80);
let labels = labels(&state, 80);
let rendered: Vec<String> = state
.parsed
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let row = rendered
.iter()
.position(|t| t.contains("continuation"))
.expect("the continuation must render");
assert_eq!(
labels[row],
Some(4),
"row {row} shows `continuation` (line index 4): {labels:?}"
);
assert!(
!labels.iter().flatten().any(|&l| l == 3),
"the swallowed blank must not be numbered elsewhere: {labels:?}"
);
}
#[test]
fn fixture_labels_are_unique_ascending_and_agree_with_the_cursor() {
let source = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/sample.md"
))
.expect("the sample fixture must be readable");
for width in [100, 60] {
let mut state = state_for(&source, width);
let labels = labels(&state, width);
let numbered: Vec<usize> = labels.iter().flatten().copied().collect();
assert!(
numbered.iter().all(|&l| l < state.buffer.line_count()),
"width {width}: a label named a line past the end of the buffer"
);
assert!(
numbered.windows(2).all(|w| w[0] < w[1]),
"width {width}: labels must ascend and be unique, got {numbered:?}"
);
for (row, line) in labels
.iter()
.enumerate()
.filter_map(|(row, l)| l.map(|line| (row, line)))
{
state.cursor.offset = state.buffer.line_to_char(line);
state.update_cursor_block();
assert_eq!(
state.cursor_visual_row(width),
row,
"width {width}: the gutter labels row {row} with line {line}, \
but the cursor on that line lands elsewhere"
);
}
}
}
#[test]
fn typing_within_a_line_does_not_rebuild_the_table() {
let mut state = state_for("alpha\n\nbravo\n", 80);
let before = builds(|| {
let _ = state.source_line_at_visual_row(0, 80);
});
assert_eq!(before, 1, "first query builds");
state.cursor.offset = state.buffer.line_to_char(2);
type_text(&mut state, "xyz");
let rebuilds = builds(|| {
let _ = state.source_line_at_visual_row(0, 80);
});
assert_eq!(rebuilds, 0, "an in-line edit must reuse the table");
assert_eq!(labels(&state, 80), vec![Some(0), Some(1), Some(2), Some(3)]);
}
#[test]
fn inserting_a_newline_rebuilds_the_table() {
let mut state = state_for("alpha\n\nbravo\n", 80);
let _ = state.source_line_at_visual_row(0, 80);
state.cursor.offset = state.buffer.line_to_char(2);
type_text(&mut state, "\n");
let rebuilds = builds(|| {
let _ = state.source_line_at_visual_row(0, 80);
});
assert_eq!(rebuilds, 1, "a re-parse must drop the table");
assert_eq!(
labels(&state, 80),
vec![Some(0), Some(1), Some(2), Some(3), Some(4)]
);
}
#[test]
fn a_deferred_edit_does_not_shift_the_labels() {
let mut state = state_for("alpha\n\nbravo\n\ncharlie\n", 80);
state.cursor.offset = 0;
type_text(&mut state, "\n");
state.cursor.offset = 0;
type_text(&mut state, "zzz");
assert_eq!(state.buffer.contents(), "zzz\nalpha\n\nbravo\n\ncharlie\n");
assert_eq!(
labels(&state, 80),
vec![
Some(0),
Some(1),
Some(2),
Some(3),
Some(4),
Some(5),
Some(6)
],
);
}
#[test]
fn rows_past_the_end_have_no_label() {
let state = state_for("alpha\n", 80);
let total = state.parsed.total_visual_rows(80);
assert_eq!(state.source_line_at_visual_row(total, 80), None);
assert_eq!(state.source_line_at_visual_row(total + 50, 80), None);
}
}