pub mod markdown;
pub mod rules;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use crate::helix::{RopeSlice};
use crate::marks::{MarkAttrs, MarkId, Marks};
use crate::state::{Document, State};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutlineConfig {
pub indent: u8,
pub atomic_images: bool,
pub numbered: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub tags: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_tag: Option<char>,
}
impl Default for OutlineConfig {
fn default() -> Self {
OutlineConfig {
indent: 2,
atomic_images: true,
numbered: true,
tags: String::new(),
new_tag: None,
}
}
}
impl OutlineConfig {
pub fn is_tag(&self, c: char) -> bool {
self.tags.contains(c)
}
pub fn indent_str(&self, depth: u16) -> String {
" ".repeat(self.indent.max(1) as usize * depth as usize)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Para,
Bullet,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Hang {
None,
Bullet,
Number(u32),
Heading(u8),
Quote,
Fence,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlockInfo {
pub id: MarkId,
pub start: usize,
pub end: usize,
pub first_line: usize,
pub line_count: usize,
pub depth: u16,
pub kind: Kind,
pub tag: Option<char>,
pub prefix_len: usize,
pub indent: usize,
pub hang: Hang,
pub fence: bool,
pub atomic: bool,
pub gap: bool,
pub attrs: MarkAttrs,
}
impl BlockInfo {
pub fn content_start(&self) -> usize {
self.start + self.prefix_len
}
pub fn last_line(&self) -> usize {
self.first_line + self.line_count - 1
}
pub fn is_item(&self) -> bool {
self.kind != Kind::Para
}
pub fn is_plain_para(&self) -> bool {
self.kind == Kind::Para && self.hang == Hang::None && !self.fence
}
pub fn is_empty(&self) -> bool {
self.content_start() >= self.end
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Outline {
pub blocks: Vec<BlockInfo>,
line_block: Vec<u32>,
}
impl Outline {
pub fn index_of_line(&self, line: usize) -> usize {
self.line_block.get(line).or(self.line_block.last()).map_or(0, |&b| b as usize)
}
pub fn block_of_line(&self, line: usize) -> &BlockInfo {
&self.blocks[self.index_of_line(line)]
}
pub fn index_at(&self, text: RopeSlice, pos: usize) -> usize {
self.index_of_line(text.char_to_line(pos.min(text.len_chars())))
}
pub fn block_at(&self, text: RopeSlice, pos: usize) -> &BlockInfo {
&self.blocks[self.index_at(text, pos)]
}
pub fn index_of(&self, id: MarkId) -> Option<usize> {
self.blocks.iter().position(|b| b.id == id)
}
pub fn get(&self, id: MarkId) -> Option<&BlockInfo> {
self.blocks.iter().find(|b| b.id == id)
}
pub fn gap_before_line(&self, line: usize) -> bool {
let b = self.block_of_line(line);
b.first_line == line && b.gap
}
pub fn indices_between(&self, text: RopeSlice, from: usize, to: usize) -> std::ops::RangeInclusive<usize> {
self.index_at(text, from)..=self.index_at(text, to)
}
pub fn subtree_end(&self, i: usize) -> usize {
let d = self.blocks[i].depth;
let mut j = i + 1;
while j < self.blocks.len() && self.blocks[j].depth > d {
j += 1;
}
j
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Prefix {
pub indent: usize,
pub kind: Kind,
pub tag: Option<char>,
pub hang: Hang,
pub len: usize,
pub marker: bool,
pub fence: bool,
}
impl Prefix {
fn fence_content() -> Prefix {
Prefix { indent: 0, kind: Kind::Para, tag: None, hang: Hang::None, len: 0, marker: false, fence: true }
}
}
pub(crate) fn parse_prefix(line: RopeSlice, cfg: &OutlineConfig) -> Prefix {
let mut chars = line.chars();
let mut indent = 0usize;
let mut first = None;
for c in chars.by_ref() {
if c == ' ' {
indent += 1;
} else {
first = Some(c);
break;
}
}
let mut head: Vec<char> = Vec::with_capacity(14);
if let Some(c) = first {
head.push(c);
head.extend(chars.take(13));
}
parse_head(indent, &head, cfg)
}
pub(crate) fn parse_str(line: &str, cfg: &OutlineConfig) -> Prefix {
let indent = line.chars().take_while(|&c| c == ' ').count();
let head: Vec<char> = line.chars().skip(indent).take(14).collect();
parse_head(indent, &head, cfg)
}
fn parse_head(indent: usize, h: &[char], cfg: &OutlineConfig) -> Prefix {
let para = Prefix { indent, kind: Kind::Para, tag: None, hang: Hang::None, len: indent, marker: false, fence: false };
let at = |i: usize| h.get(i).copied();
match (at(0), at(1)) {
(Some('-' | '*' | '+'), Some(' ')) => {
if at(2) == Some('[') && at(4) == Some(']') && at(5) == Some(' ') {
if let Some(c) = at(3).filter(|&c| cfg.is_tag(c)) {
return Prefix { kind: Kind::Bullet, tag: Some(c), hang: Hang::Bullet, len: indent + 6, marker: true, ..para };
}
}
return Prefix { kind: Kind::Bullet, hang: Hang::Bullet, len: indent + 2, marker: true, ..para };
}
(Some('#'), _) => {
let n = h.iter().take_while(|&&c| c == '#').count();
if (1..=3).contains(&n) && at(n) == Some(' ') {
return Prefix { hang: Hang::Heading(n as u8), len: indent + n + 1, marker: true, ..para };
}
}
(Some('>'), Some(' ')) => return Prefix { hang: Hang::Quote, len: indent + 2, marker: true, ..para },
(Some('`'), Some('`')) if at(2) == Some('`') => {
return Prefix { hang: Hang::Fence, marker: true, fence: true, ..para };
}
(Some(c), _) if c.is_ascii_digit() && cfg.numbered => {
let n = h.iter().take_while(|c| c.is_ascii_digit()).count();
if n <= 9 && matches!(at(n), Some('.' | ')')) && at(n + 1) == Some(' ') {
let num: String = h[..n].iter().collect();
let num = num.parse().unwrap_or(0);
return Prefix { kind: Kind::Bullet, hang: Hang::Number(num), len: indent + n + 2, marker: true, ..para };
}
}
_ => {}
}
para
}
fn starts_fence(line: RopeSlice) -> bool {
let mut chars = line.chars().skip_while(|&c| c == ' ');
chars.next() == Some('`') && chars.next() == Some('`') && chars.next() == Some('`')
}
fn line_ending_len(line: RopeSlice) -> usize {
crate::helix::line_ending::get_line_ending(&line).map_or(0, |le| le.len_chars())
}
pub fn default_gap(a: Option<&BlockInfo>, b: &BlockInfo) -> bool {
let Some(a) = a else { return false };
let para = |l: &BlockInfo| l.kind == Kind::Para;
let sub = |l: &BlockInfo| matches!(l.hang, Hang::Heading(2 | 3));
let heading = |l: &BlockInfo| matches!(l.hang, Hang::Heading(_));
let after = para(a) && !sub(a);
let before = para(b) && heading(b);
after || before || (para(b) && !para(a))
}
pub fn derive(text: RopeSlice, marks: &Marks, cfg: &OutlineConfig) -> Outline {
let mut blocks: Vec<BlockInfo> = Vec::new();
let mut line_block: Vec<u32> = Vec::with_capacity(text.len_lines());
let mut in_fence = false;
let mut line_start = 0usize;
let mut next_mark = marks.iter().peekable();
for (i, line) in text.lines().enumerate() {
let len = line.len_chars();
let content_end = line_start + len - line_ending_len(line);
while next_mark.peek().is_some_and(|m| m.pos < line_start) {
next_mark.next();
}
let mark = next_mark.peek().filter(|m| m.pos == line_start).copied().cloned();
let prefix = if in_fence { None } else { Some(parse_prefix(line, cfg)) };
let starts = i == 0 || mark.is_some() || prefix.is_some_and(|p| p.marker);
let mut opened = false;
if starts {
let p = prefix.unwrap_or_else(Prefix::fence_content);
let depth = (p.indent / cfg.indent.max(1) as usize) as u16;
if p.hang == Hang::Fence {
in_fence = true;
opened = true;
}
blocks.push(BlockInfo {
id: mark.as_ref().map_or(MarkId(u64::MAX), |m| m.id),
start: line_start,
end: content_end,
first_line: i,
line_count: 1,
depth,
kind: p.kind,
tag: p.tag,
prefix_len: p.len.min(content_end - line_start),
indent: p.indent,
hang: p.hang,
fence: p.fence,
atomic: false,
gap: false,
attrs: mark.map(|m| m.attrs.clone()).unwrap_or_default(),
});
} else if let Some(b) = blocks.last_mut() {
b.line_count += 1;
b.end = content_end;
}
if in_fence && !opened && starts_fence(line) {
in_fence = false;
}
line_block.push((blocks.len() - 1) as u32);
line_start += len;
}
for i in 0..blocks.len() {
let gap = {
let (before, rest) = blocks.split_at(i);
let b = &rest[0];
match b.attrs.gap {
Some(g) if i > 0 => g,
_ => default_gap(before.last(), b),
}
};
let b = &mut blocks[i];
b.gap = gap;
if cfg.atomic_images && b.line_count == 1 && !b.fence {
b.atomic = is_image(text.slice(b.content_start()..b.end));
}
}
Outline { blocks, line_block }
}
pub fn is_image(content: RopeSlice) -> bool {
let n = content.len_chars();
if n < 5 || content.char(0) != '!' || content.char(1) != '[' || content.char(n - 1) != ')' {
return false;
}
let s: String = content.chars().collect();
match s.find("](") {
Some(k) => !s[2..k].contains(']') && !s[k + 2..s.len() - 1].contains(')'),
None => false,
}
}
#[derive(Default)]
pub struct OutlineCache(Mutex<Memo>);
#[derive(Clone, Default)]
struct Memo {
now: Option<Arc<Outline>>,
before: Option<(Arc<Outline>, usize, Option<(usize, usize)>)>,
len: usize,
}
impl Clone for OutlineCache {
fn clone(&self) -> Self {
OutlineCache(Mutex::new(self.0.lock().map(|g| g.clone()).unwrap_or_default()))
}
}
impl PartialEq for OutlineCache {
fn eq(&self, _: &OutlineCache) -> bool {
true
}
}
impl std::fmt::Debug for OutlineCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "OutlineCache")
}
}
impl OutlineCache {
pub fn clear(&mut self) {
*self.memo() = Memo::default();
}
fn memo(&mut self) -> &mut Memo {
self.0.get_mut().unwrap_or_else(|e| e.into_inner())
}
pub(crate) fn marks_changed(&mut self) {
let m = self.memo();
if let Some(now) = m.now.take() {
m.before = Some((now, m.len, None));
}
}
pub(crate) fn edited(&mut self, cs: &crate::helix::ChangeSet) {
let m = self.memo();
if let Some(now) = m.now.take() {
m.before = Some((now, m.len, None));
}
if let Some((_, _, range)) = &mut m.before {
*range = Some(crate::state::changed_span(*range, cs));
}
}
fn get(&self) -> Option<Arc<Outline>> {
self.0.lock().ok().and_then(|g| g.now.clone())
}
fn before(&self) -> Option<(Arc<Outline>, usize, Option<(usize, usize)>)> {
self.0.lock().ok().and_then(|g| g.before.clone())
}
fn put(&self, o: Arc<Outline>, len: usize) {
if let Ok(mut g) = self.0.lock() {
*g = Memo { now: Some(o), before: None, len };
}
}
}
impl Document {
pub fn blocks(&self) -> Option<Arc<Outline>> {
let cfg = self.outline.as_ref()?;
let lines = self.text.len_lines();
if let Some(o) = self.derived.get() {
if o.line_block.len() == lines && o.blocks.len() <= self.marks.len().max(1) + lines {
return Some(o);
}
}
let text = self.text.slice(..);
let o = match self.derived.before() {
Some((prev, len, range)) => {
let range = range.unwrap_or((0, 0));
let o = derive_from(&prev, len, range, text, &self.marks, cfg).unwrap_or_else(|| derive(text, &self.marks, cfg));
#[cfg(debug_assertions)]
assert_eq!(o, derive(text, &self.marks, cfg), "the outline derived around a change is the whole derivation");
o
}
None => derive(text, &self.marks, cfg),
};
let o = Arc::new(o);
self.derived.put(o.clone(), self.text.len_chars());
Some(o)
}
}
pub fn derive_from(prev: &Outline, prev_len: usize, (from, to): (usize, usize), text: RopeSlice, marks: &Marks, cfg: &OutlineConfig) -> Option<Outline> {
if prev.blocks.is_empty() || prev.line_block.is_empty() {
return None;
}
let len = text.len_chars();
let delta = len as isize - prev_len as isize;
let prev_lines = prev.line_block.len();
let dl = text.len_lines() as isize - prev_lines as isize;
let (from, to) = (from.min(len), to.min(len).max(from.min(len)));
let mut j = prev.index_of_line(text.char_to_line(from).min(prev_lines - 1));
j = j.saturating_sub(1);
while j > 0 && prev.blocks[j].fence {
j -= 1;
}
let first = &prev.blocks[j];
let mut blocks: Vec<BlockInfo> = prev.blocks[..j].to_vec();
let mut line_block: Vec<u32> = prev.line_block[..first.first_line].to_vec();
let mut in_fence = false;
let mut line_start = first.start;
let mut tail: Option<(usize, usize)> = None;
let mut i = first.first_line;
let mut lines = text.lines_at(i);
let derived_from = j;
while let Some(line) = lines.next() {
let len_l = line.len_chars();
let content_end = line_start + len_l - line_ending_len(line);
let prefix = if in_fence { None } else { Some(parse_prefix(line, cfg)) };
let mark_here = marks.at(line_start).is_some();
let starts = i == 0 || mark_here || prefix.is_some_and(|p| p.marker);
if starts && !in_fence && line_start >= to && i > first.first_line {
let old_line = i as isize - dl;
if old_line >= 0 && (old_line as usize) < prev_lines {
let k = prev.index_of_line(old_line as usize);
let b = &prev.blocks[k];
if b.first_line == old_line as usize && b.start as isize == line_start as isize - delta && !b.fence {
tail = Some((k, old_line as usize));
break;
}
}
}
let mut opened = false;
if starts {
let p = prefix.unwrap_or_else(Prefix::fence_content);
let depth = (p.indent / cfg.indent.max(1) as usize) as u16;
if p.hang == Hang::Fence {
in_fence = true;
opened = true;
}
blocks.push(BlockInfo {
id: MarkId(u64::MAX),
start: line_start,
end: content_end,
first_line: i,
line_count: 1,
depth,
kind: p.kind,
tag: p.tag,
prefix_len: p.len.min(content_end - line_start),
indent: p.indent,
hang: p.hang,
fence: p.fence,
atomic: false,
gap: false,
attrs: MarkAttrs::default(),
});
} else if let Some(b) = blocks.last_mut() {
b.line_count += 1;
b.end = content_end;
}
if in_fence && !opened && starts_fence(line) {
in_fence = false;
}
line_block.push((blocks.len() - 1) as u32);
line_start += len_l;
i += 1;
}
let derived_to = blocks.len();
if let Some((k, old_line)) = tail {
let shift = |b: &BlockInfo| BlockInfo {
start: (b.start as isize + delta) as usize,
end: (b.end as isize + delta) as usize,
first_line: (b.first_line as isize + dl) as usize,
..b.clone()
};
let base = blocks.len() as isize - k as isize;
blocks.extend(prev.blocks[k..].iter().map(shift));
line_block.extend(prev.line_block[old_line..].iter().map(|&x| (x as isize + base) as u32));
} else if line_block.len() != text.len_lines() {
return None;
}
let mut ms = marks.iter().peekable();
for b in blocks.iter_mut() {
if ms.peek().is_some_and(|m| m.pos < b.start) {
return None;
}
match ms.peek() {
Some(m) if m.pos == b.start => {
b.id = m.id;
b.attrs = m.attrs.clone();
ms.next();
}
_ => {
if b.first_line > 0 && b.hang == Hang::None && b.kind == Kind::Para {
return None;
}
b.id = MarkId(u64::MAX);
b.attrs = MarkAttrs::default();
}
}
}
if ms.next().is_some() {
return None;
}
for i in 0..blocks.len() {
let gap = {
let (before, rest) = blocks.split_at(i);
let b = &rest[0];
match b.attrs.gap {
Some(g) if i > 0 => g,
_ => default_gap(before.last(), b),
}
};
let b = &mut blocks[i];
b.gap = gap;
if (derived_from..derived_to).contains(&i) && cfg.atomic_images && b.line_count == 1 && !b.fence {
b.atomic = is_image(text.slice(b.content_start()..b.end));
}
}
Some(Outline { blocks, line_block })
}
impl State {
pub fn blocks(&self) -> Option<Arc<Outline>> {
self.doc.blocks()
}
pub fn enable_outline(&mut self, cfg: OutlineConfig) {
self.doc.outline = Some(cfg);
self.doc.derived.clear();
mint_missing(&mut self.doc);
rules::normalize(self, &self.view.selection.clone(), &crate::Msg::Tick { now_ms: self.doc.now_ms });
}
pub fn outline_changed(&mut self) {
self.doc.derived.clear();
self.doc.touch_all();
if self.doc.outline.is_some() {
mint_missing(&mut self.doc);
rules::normalize(self, &self.view.selection.clone(), &crate::Msg::Tick { now_ms: self.doc.now_ms });
}
}
}
pub(crate) fn mint_missing(doc: &mut Document) -> bool {
let Some(o) = doc.blocks() else { return false };
let missing: Vec<usize> = o.blocks.iter().filter(|b| b.id == MarkId(u64::MAX)).map(|b| b.start).collect();
if missing.is_empty() {
return false;
}
for pos in missing {
doc.marks.mint(pos);
}
doc.derived.marks_changed();
true
}
pub fn content(doc: &Document, id: MarkId) -> Option<String> {
let o = doc.blocks()?;
let b = o.get(id)?;
Some(doc.text.slice(b.content_start()..b.end).to_string().replace("\r\n", "\n"))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewBlock {
#[serde(default)]
pub depth: u16,
pub kind: Kind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<char>,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gap: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mark: Option<MarkId>,
}
impl NewBlock {
pub fn para(text: &str) -> NewBlock {
NewBlock { depth: 0, kind: Kind::Para, tag: None, text: text.into(), gap: None, mark: None }
}
pub fn to_lines(&self, cfg: &OutlineConfig) -> String {
let indent = cfg.indent_str(self.depth);
let marker = match self.kind {
Kind::Bullet if numbered_marker(&self.text).is_some() => String::new(),
Kind::Bullet if self.tag.is_some() => format!("- [{}] ", self.tag.unwrap_or(' ')),
Kind::Bullet => "- ".into(),
Kind::Para => String::new(),
};
format!("{indent}{marker}{}", self.text)
}
}
pub(crate) fn numbered_marker(text: &str) -> Option<usize> {
let n = text.chars().take_while(|c| c.is_ascii_digit()).count();
let rest = &text[n..];
(n > 0 && n <= 9 && (rest.starts_with(". ") || rest.starts_with(") "))).then_some(n + 2)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helix::Rope;
fn kinds(text: &str) -> Vec<(usize, Kind, u16, usize)> {
let rope = Rope::from(text);
let o = derive(rope.slice(..), &Marks::new(), &OutlineConfig::default());
o.blocks.iter().map(|b| (b.first_line, b.kind, b.depth, b.line_count)).collect()
}
#[test]
fn markers_start_blocks_and_other_lines_continue_them() {
assert_eq!(
kinds("Intro\nmore\n- a\n - [ ] b\nnote\n1. one\n# Head"),
[
(0, Kind::Para, 0, 2),
(2, Kind::Bullet, 0, 1),
(3, Kind::Bullet, 1, 2),
(5, Kind::Bullet, 0, 1),
(6, Kind::Para, 0, 1)
]
);
}
#[test]
fn nothing_starts_a_block_inside_a_fence() {
assert_eq!(kinds("```\n- a\n```\n- b"), [(0, Kind::Para, 0, 3), (3, Kind::Bullet, 0, 1)]);
}
#[test]
fn a_whole_line_image_is_atomic() {
let rope = Rope::from("\n and text");
let o = derive(rope.slice(..), &Marks::new(), &OutlineConfig::default());
assert_eq!(o.blocks.len(), 1, "the second line continues the first");
assert!(!o.blocks[0].atomic, "two lines are not one image");
let rope = Rope::from("");
let o = derive(rope.slice(..), &Marks::new(), &OutlineConfig::default());
assert!(o.blocks[0].atomic);
}
}