use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
use regex::Regex;
use crate::config::Config;
use crate::error::Result;
use crate::model::ItemKind;
use crate::repo::Repo;
use crate::review;
use crate::style;
use crate::{bail, log, logdim, logwarn, spar_err};
static ITEM: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"^(?P<indent>[ \t]*)(?:[-*+]|[0-9]{1,9}[.)])(?P<gap>[ \t]+)\[(?P<state>[ xX])\](?P<rest>[ \t].*|)$",
)
.expect("task item pattern")
});
fn item_of(line: &str) -> Option<regex::Captures<'_>> {
let caps = ITEM.captures(line)?;
(indent_width(&caps["gap"]) <= 4).then_some(caps)
}
static LIST: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?P<indent>[ \t]*)(?P<marker>[-*+]|[0-9]{1,9}[.)])(?P<gap>[ \t]*)(?P<rest>.*)$")
.expect("list pattern")
});
static HTML_VERBATIM: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[ \t]*<(?:pre|script|style|textarea)\b").expect("html open pattern")
});
static HTML_CLOSE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)</(?:pre|script|style|textarea)>").expect("html close pattern")
});
static HTML_BLOCK: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i)^[ \t]*</?(?:address|article|aside|base|basefont|blockquote|body|caption|center",
r"|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form",
r"|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem",
r"|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot",
r"|th|thead|title|tr|track|ul)\b"
))
.expect("html block pattern")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Html {
Verbatim,
Block,
}
static FENCE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[ \t]*(?P<fence>`{3,}|~{3,})(?P<info>.*)$").expect("fence pattern")
});
static HASH_REF: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|[^\w/])(?:(?P<slug>[\w.-]+/[\w.-]+)#|#|GH-)(?P<number>[0-9]{1,9})\b")
.expect("hash pattern")
});
static LINK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"https?://[^\s<>)\]]*").expect("link pattern"));
static COMMENT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)<!--.*?(?:-->|$)").expect("comment pattern"));
static URL_REF: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?P<url>https?://[^\s)\]]*?/(?:issues|pull)/(?P<number>[0-9]{1,9}))\b")
.expect("url pattern")
});
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Origin {
Here,
Repo(String),
Url(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reference {
pub number: i64,
pub origin: Origin,
}
impl Reference {
pub fn local(&self, home: &str) -> Option<i64> {
match &self.origin {
Origin::Here => Some(self.number),
Origin::Repo(slug) => (slug == &owner_repo(home)).then_some(self.number),
Origin::Url(url) => {
let home = locator(home).trim_end_matches('/');
let url = locator(url);
let owned = !home.is_empty()
&& (url.starts_with(&format!("{home}/issues/"))
|| url.starts_with(&format!("{home}/pull/")));
owned.then_some(self.number)
}
}
}
pub fn names(&self) -> String {
match &self.origin {
Origin::Here => format!("#{}", self.number),
Origin::Repo(slug) => format!("{slug}#{}", self.number),
Origin::Url(url) => url.clone(),
}
}
}
fn owner_repo(home: &str) -> String {
let path: Vec<&str> = locator(home).trim_matches('/').split('/').collect();
match path.len() {
0..=2 => String::new(),
n => format!("{}/{}", path[n - 2], path[n - 1]),
}
}
fn locator(url: &str) -> &str {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
rest.strip_prefix("www.").unwrap_or(rest)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub line: usize,
pub raw: String,
pub text: String,
pub checked: bool,
pub reference: Option<Reference>,
}
pub fn parse(body: &str) -> Vec<Item> {
let mut out = Vec::new();
let mut fence: Option<(char, usize, usize)> = None;
let mut comment = false;
let mut html: Option<Html> = None;
let mut open: Vec<usize> = Vec::new();
for (index, raw) in split_keep(body).into_iter().enumerate() {
let line = without_eol(raw);
if comment {
comment = !line.contains("-->");
continue;
}
if let Some(kind) = html {
let ends = match kind {
Html::Verbatim => HTML_CLOSE.is_match(line),
Html::Block => line.trim().is_empty(),
};
if ends {
html = None;
}
continue;
}
let indent = indent_width(line);
if let Some((glyph, len, column)) = fence {
if let Some(caps) = FENCE.captures(line) {
let marker = &caps["fence"];
let closes = marker.starts_with(glyph)
&& marker.len() >= len
&& caps["info"].trim().is_empty()
&& indent < column + 4;
if closes {
fence = None;
}
}
continue;
}
if !line.trim().is_empty() {
while open.last().is_some_and(|col| indent < *col) {
open.pop();
}
}
let margin = open.last().copied().unwrap_or(0);
if !line.trim().is_empty() && indent >= margin + 4 {
continue;
}
if let Some(caps) = FENCE.captures(line) {
let marker = &caps["fence"];
fence = Some((
marker.chars().next().expect("a fence"),
marker.len(),
indent,
));
continue;
}
if let Some(column) = content_column(line) {
open.push(column);
}
if HTML_VERBATIM.is_match(line) {
html = (!HTML_CLOSE.is_match(line)).then_some(Html::Verbatim);
continue;
}
if HTML_BLOCK.is_match(line) {
html = Some(Html::Block);
continue;
}
comment = opens_comment(line);
let Some(caps) = item_of(line) else {
continue;
};
let text = caps["rest"].trim().to_string();
out.push(Item {
line: index + 1,
raw: line.to_string(),
reference: reference_in(&text),
text,
checked: &caps["state"] != " ",
});
}
out
}
fn content_column(line: &str) -> Option<usize> {
let caps = LIST.captures(line)?;
let gap = indent_width(&caps["gap"]);
if gap == 0 && !caps["rest"].is_empty() {
return None;
}
let gap = match (1..=4).contains(&gap) && !caps["rest"].trim().is_empty() {
true => gap,
false => 1,
};
Some(indent_width(&caps["indent"]) + caps["marker"].len() + gap)
}
fn reference_in(text: &str) -> Option<Reference> {
let text = &readable(text);
let url = URL_REF.captures(text);
let outside = blank(text, &LINK);
let hash = HASH_REF.captures(&outside);
let at = |caps: &Option<regex::Captures>| {
caps.as_ref()
.map(|c| c.get(0).expect("the whole match").start())
};
match (at(&url), at(&hash)) {
(Some(u), Some(h)) if h < u => hash.map(as_hash),
(Some(_), _) => url.map(as_url),
(None, Some(_)) => hash.map(as_hash),
(None, None) => None,
}
}
fn as_hash(caps: regex::Captures) -> Reference {
Reference {
number: caps["number"].parse().unwrap_or_default(),
origin: match caps.name("slug") {
Some(slug) => Origin::Repo(slug.as_str().to_string()),
None => Origin::Here,
},
}
}
fn as_url(caps: regex::Captures) -> Reference {
Reference {
number: caps["number"].parse().unwrap_or_default(),
origin: Origin::Url(caps["url"].to_string()),
}
}
fn blank(text: &str, what: &Regex) -> String {
let mut out = text.as_bytes().to_vec();
for found in what.find_iter(text) {
out[found.range()].fill(b' ');
}
String::from_utf8(out).unwrap_or_else(|_| text.to_string())
}
fn readable(text: &str) -> String {
let text = blank(text, &COMMENT);
let text = text.as_str();
let bytes = text.as_bytes();
let mut out = bytes.to_vec();
let mut at = 0;
while at < bytes.len() {
match bytes[at] {
b'`' => {
let start = at;
while at < bytes.len() && bytes[at] == b'`' {
at += 1;
}
if let Some(end) = backtick_run(bytes, at, at - start) {
out[start..end].fill(b' ');
at = end;
}
}
b'[' => match label_end(bytes, at) {
Some(end) => {
out[at..end].fill(b' ');
at = end;
}
None => at += 1,
},
_ => at += 1,
}
}
String::from_utf8(out).unwrap_or_else(|_| text.to_string())
}
fn backtick_run(bytes: &[u8], from: usize, len: usize) -> Option<usize> {
let mut at = from;
while at < bytes.len() {
if bytes[at] != b'`' {
at += 1;
continue;
}
let start = at;
while at < bytes.len() && bytes[at] == b'`' {
at += 1;
}
if at - start == len {
return Some(at);
}
}
None
}
fn label_end(bytes: &[u8], open: usize) -> Option<usize> {
if bytes.get(open) != Some(&b'[') || escaped(bytes, open) {
return None;
}
let mut depth = 1usize;
let mut at = open + 1;
while at < bytes.len() {
if escaped(bytes, at) {
at += 1;
continue;
}
match bytes[at] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
return (bytes.get(at + 1) == Some(&b'(')).then_some(at + 1);
}
}
_ => {}
}
at += 1;
}
None
}
fn escaped(bytes: &[u8], at: usize) -> bool {
let mut slashes = 0usize;
let mut cursor = at;
while cursor > 0 && bytes[cursor - 1] == b'\\' {
slashes += 1;
cursor -= 1;
}
slashes % 2 == 1
}
fn split_keep(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
for (at, c) in text.char_indices() {
if c == '\n' {
out.push(&text[start..=at]);
start = at + 1;
}
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
fn indent_width(line: &str) -> usize {
line.chars()
.take_while(|c| matches!(c, ' ' | '\t'))
.map(|c| if c == '\t' { 4 } else { 1 })
.sum()
}
fn opens_comment(line: &str) -> bool {
match line.rfind("<!--") {
Some(at) => !line[at + 4..].contains("-->"),
None => false,
}
}
fn without_eol(line: &str) -> &str {
match line.strip_suffix('\n') {
Some(rest) => rest.strip_suffix('\r').unwrap_or(rest),
None => line,
}
}
fn eol_of(line: &str) -> &str {
if line.ends_with("\r\n") {
"\r\n"
} else if line.ends_with('\n') {
"\n"
} else {
""
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change {
Tick,
Reference(String),
}
impl Change {
pub fn inserted(&self) -> &str {
match self {
Change::Tick => "x",
Change::Reference(reference) => reference,
}
}
}
pub fn rewrite(body: &str, raw: &str, change: &Change) -> Result<String> {
let lines = split_keep(body);
if lines.concat() != body {
bail!("could not split the body into lines without changing it");
}
let hits: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, line)| without_eol(line) == raw)
.map(|(at, _)| at)
.collect();
match hits.len() {
0 => bail!("that line is no longer in the body"),
1 => {}
n => bail!("{n} lines read exactly alike, so the edit could go to either"),
}
let at = hits[0];
let replaced = changed(raw, change)?;
let mut out = String::with_capacity(body.len() + replaced.len());
for (index, line) in lines.iter().enumerate() {
if index == at {
out.push_str(&replaced);
out.push_str(eol_of(line));
} else {
out.push_str(line);
}
}
let after = split_keep(&out);
if after.len() != lines.len() {
bail!(
"the edit changed the line count from {} to {}",
lines.len(),
after.len()
);
}
for (index, (before, now)) in lines.iter().zip(&after).enumerate() {
if index != at && before != now {
bail!(
"the edit would have changed line {}, which it must not",
index + 1
);
}
}
Ok(out)
}
fn changed(line: &str, change: &Change) -> Result<String> {
let caps = item_of(line).ok_or_else(|| spar_err!("that line is no longer a checklist item"))?;
match change {
Change::Tick => {
let at = caps.name("state").expect("a state").start();
if &line[at..at + 1] != " " {
bail!("that box is already ticked");
}
Ok(format!("{}x{}", &line[..at], &line[at + 1..]))
}
Change::Reference(reference) => {
let rest = caps.name("rest").expect("a rest");
let text = rest.as_str();
let body = text.trim_end_matches([' ', '\t']);
if body.trim().is_empty() {
bail!("that item has no text to attach {reference} to");
}
Ok(format!(
"{}{body} {reference}{}",
&line[..rest.start()],
&text[body.len()..]
))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Shape {
Names(i64),
Needs,
Hold(String),
Over,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Adopt(i64),
Tick(i64),
Link {
number: i64,
title: String,
open: bool,
},
File,
Hold(String),
Over,
}
impl Action {
pub fn change(&self) -> Option<Change> {
match self {
Action::Tick(_) => Some(Change::Tick),
Action::Link { number, .. } => Some(Change::Reference(format!("#{number}"))),
Action::Adopt(_) | Action::File | Action::Hold(_) | Action::Over => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Step {
pub item: Item,
pub action: Action,
}
fn shape(body: &str, home: &str, max: usize) -> Vec<(Item, Shape)> {
let items = parse(body);
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for item in &items {
*seen.entry(item.raw.as_str()).or_default() += 1;
}
let mut out = Vec::new();
let mut taken = 0usize;
for item in &items {
if item.checked {
continue;
}
let shape = if seen.get(item.raw.as_str()).copied().unwrap_or(0) > 1 {
Shape::Hold("another item is written identically, so a link could go to either".into())
} else if item.text.is_empty() {
Shape::Hold("the item has no text".into())
} else if taken >= max {
Shape::Over
} else {
match &item.reference {
Some(reference) => match reference.local(home) {
Some(number) => {
taken += 1;
Shape::Names(number)
}
None => Shape::Hold(format!(
"it names an issue in another repository: {}",
reference.names()
)),
},
None => {
taken += 1;
Shape::Needs
}
}
};
out.push((item.clone(), shape));
}
out
}
pub fn plan(repo: &Repo, cfg: &Config, tracker: i64, body: &str, home: &str) -> Vec<Step> {
shape(body, home, cfg.loop_cfg.max_tracker_children)
.into_iter()
.map(|(item, shape)| {
let action = match shape {
Shape::Hold(why) => Action::Hold(why),
Shape::Over => Action::Over,
Shape::Names(number) => resolve(repo, number),
Shape::Needs => match search(repo, tracker, &item.text) {
Some(found) => Action::Link {
number: found.number,
title: found.title,
open: found.open,
},
None => Action::File,
},
};
Step { item, action }
})
.collect()
}
fn resolve(repo: &Repo, number: i64) -> Action {
match repo.item_kind(number) {
Ok(ItemKind::Issue) => match repo.read_issue(number) {
Ok(issue) if issue.is_closed() => Action::Tick(number),
Ok(_) => Action::Adopt(number),
Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
},
Ok(ItemKind::Pr) => match repo.pr_state(number).to_uppercase().as_str() {
"MERGED" => Action::Tick(number),
"" => Action::Hold(format!(
"#{number} is a pull request in an unreadable state"
)),
state => Action::Hold(format!(
"#{number} is a pull request, {}",
state.to_lowercase()
)),
},
Err(e) => Action::Hold(format!("could not read #{number}: {}", e.first_line())),
}
}
fn search(repo: &Repo, tracker: i64, text: &str) -> Option<crate::repo::ExistingIssue> {
let title = repo.clean_title(text).ok()?;
repo.find_similar_issue_apart_from(&title, &child_body(text, tracker), Some(tracker))
}
fn child_body(text: &str, tracker: i64) -> String {
format!("{text}\n\nFrom the checklist in #{tracker}.")
}
pub fn decompose(cfg: &Config, repo: &Repo, tracker: i64) -> Vec<i64> {
let Some((body, slug)) = read_for_write(repo, tracker) else {
return Vec::new();
};
let steps = plan(repo, cfg, tracker, &body, &slug);
if steps.is_empty() {
logdim!("#{tracker} has no unchecked checklist items, so there is nothing to extract");
return Vec::new();
}
log!("#{tracker}: {} unchecked checklist item(s)", steps.len());
report_overflow(cfg, tracker, &steps);
apply(repo, tracker, &steps)
}
fn apply(repo: &Repo, tracker: i64, steps: &[Step]) -> Vec<i64> {
let mut children = Vec::new();
for step in steps {
let what = style::clip(&style::one_line(&step.item.text), 80);
match &step.action {
Action::Hold(why) => logdim!(" left '{what}' alone: {why}"),
Action::Over => {}
Action::Adopt(number) => {
log!(" '{what}' is already #{number}");
children.push(*number);
}
Action::Tick(number) => {
let Some(change) = step.action.change() else {
continue;
};
if write(repo, tracker, &step.item.raw, &change) {
log!(" ticked '{what}' off, #{number} is finished");
}
}
Action::Link {
number,
title,
open,
} => {
log!(" linking '{what}' to #{number} '{title}', filed nothing");
let Some(change) = step.action.change() else {
continue;
};
if write(repo, tracker, &step.item.raw, &change) && *open {
children.push(*number);
}
}
Action::File => {
let Ok(title) = repo.clean_nonempty_title_for_write(&step.item.text) else {
logdim!(" could not clean a title out of '{what}'");
continue;
};
if !still_asked_for(repo, tracker, &step.item.raw) {
logdim!(" '{what}' is no longer in #{tracker}, so nothing was filed for it");
continue;
}
match review::file_as_issue_apart_from(
repo,
&title,
&child_body(&step.item.text, tracker),
Some(tracker),
) {
Ok(filed) => {
let number = filed.issue();
log!(" {} for '{what}'", filed.note());
let linked = write(
repo,
tracker,
&step.item.raw,
&Change::Reference(format!("#{number}")),
);
match linked {
true if filed.number().is_some() => children.push(number),
true => {}
false => logwarn!(
" '{what}' went to #{number}, but #{tracker} does not link to it"
),
}
}
Err(e) => logdim!(" could not file an issue for '{what}': {e}"),
}
}
}
}
unique_children(children)
}
fn unique_children(mut children: Vec<i64>) -> Vec<i64> {
let mut seen = BTreeSet::new();
children.retain(|number| seen.insert(*number));
children
}
fn still_an_item(body: &str, raw: &str) -> bool {
let lines = split_keep(body)
.into_iter()
.filter(|line| without_eol(line) == raw)
.count();
lines == 1 && parse(body).iter().filter(|item| item.raw == raw).count() == 1
}
fn still_asked_for(repo: &Repo, tracker: i64, raw: &str) -> bool {
match repo.record_failed_write(repo.read_issue(tracker)) {
Ok(issue) => still_an_item(issue.body_text(), raw),
Err(e) => {
logdim!(" could not re-read #{tracker}: {}", e.first_line());
false
}
}
}
fn write(repo: &Repo, tracker: i64, raw: &str, change: &Change) -> bool {
let body = match repo.record_failed_write(repo.read_issue(tracker)) {
Ok(issue) => issue.body_text().to_string(),
Err(e) => {
logdim!(" could not re-read #{tracker}: {}", e.first_line());
return false;
}
};
if !still_an_item(&body, raw) {
logdim!(" not editing #{tracker}: that line is no longer a checklist item in it");
return false;
}
let updated = match rewrite(&body, raw, change) {
Ok(updated) => updated,
Err(e) => {
logdim!(" not editing #{tracker}: {}", e.first_line());
return false;
}
};
match repo.edit_issue_body(tracker, &body, &updated, change.inserted()) {
Ok(()) => true,
Err(e) => {
logdim!(" could not edit #{tracker}: {}", e.first_line());
false
}
}
}
fn report_overflow(cfg: &Config, tracker: i64, steps: &[Step]) {
let left: Vec<String> = steps
.iter()
.filter(|s| s.action == Action::Over)
.map(|s| format!("'{}'", style::clip(&style::one_line(&s.item.text), 60)))
.collect();
if left.is_empty() {
return;
}
logwarn!(
"#{tracker} has more unchecked items than max_tracker_children ({}), so {} were left for \
a later run: {}",
cfg.loop_cfg.max_tracker_children,
left.len(),
left.join(", ")
);
}
fn read(repo: &Repo, tracker: i64) -> Option<(String, String)> {
match repo.read_issue(tracker) {
Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
Err(e) => {
logdim!("could not read #{tracker}: {}", e.first_line());
None
}
}
}
fn read_for_write(repo: &Repo, tracker: i64) -> Option<(String, String)> {
match repo.record_failed_write(repo.read_issue(tracker)) {
Ok(issue) => Some((issue.body_text().to_string(), home_of(&issue.url))),
Err(e) => {
logdim!("could not read #{tracker}: {}", e.first_line());
None
}
}
}
fn home_of(url: &str) -> String {
match url.rfind("/issues/") {
Some(at) => url[..at].to_string(),
None => String::new(),
}
}
pub fn preview(cfg: &Config, repo: &Repo, tracker: i64) {
let Some((body, slug)) = read(repo, tracker) else {
return;
};
let steps = plan(repo, cfg, tracker, &body, &slug);
if steps.is_empty() {
return;
}
println!("\n#{tracker}, if decompose_trackers let it act on the checklist:");
let mut projected = body.clone();
for step in &steps {
let what = style::clip(&style::one_line(&step.item.text), 80);
match &step.action {
Action::Adopt(number) => println!(" keep '{what}' is already #{number}"),
Action::Tick(number) => println!(" tick '{what}', #{number} is finished"),
Action::Link {
number,
title,
open,
} => {
let state = if *open { "open" } else { "closed" };
println!(" link '{what}' to #{number} '{title}' ({state}), filing nothing");
}
Action::File => println!(" file '{what}'"),
Action::Over => println!(" over '{what}' is past max_tracker_children"),
Action::Hold(why) => println!(" hold '{what}': {why}"),
}
let change = match &step.action {
Action::File => Some(Change::Reference(FILED.to_string())),
other => other.change(),
};
let Some(change) = change else { continue };
match rewrite(&projected, &step.item.raw, &change) {
Ok(next) => projected = next,
Err(e) => println!(" the line will not be rewritten: {e}"),
}
}
let diff = diff(&body, &projected);
if diff.is_empty() {
println!(" nothing would be written to the body");
} else {
println!(" and the body it would write:");
for line in diff {
println!(" {line}");
}
}
}
const FILED: &str = "#(the issue it files)";
fn diff(before: &str, after: &str) -> Vec<String> {
split_keep(before)
.into_iter()
.zip(split_keep(after))
.filter(|(old, new)| old != new)
.flat_map(|(old, new)| {
[
format!("- {}", without_eol(old)),
format!("+ {}", without_eol(new)),
]
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const HOME: &str = "https://github.com/me/mine";
fn texts(body: &str) -> Vec<String> {
parse(body).into_iter().map(|i| i.text).collect()
}
#[test]
fn the_ordinary_checklist_is_read_as_items() {
let items = parse("Some prose.\n\n- [ ] first\n- [x] second\n");
assert_eq!(2, items.len());
assert_eq!("first", items[0].text);
assert!(!items[0].checked);
assert!(items[1].checked);
assert_eq!(3, items[0].line);
}
#[test]
fn indented_and_nested_items_are_items() {
let body = "- [ ] parent\n - [ ] child\n\t- [ ] tabbed\n * [ ] deeper\n1. [ ] ordered\n2) [ ] also ordered\n";
assert_eq!(
vec![
"parent",
"child",
"tabbed",
"deeper",
"ordered",
"also ordered"
],
texts(body)
);
}
#[test]
fn something_that_looks_like_an_item_inside_a_fence_is_not_one() {
let body = "\
- [ ] real
```markdown
- [ ] not real
```
~~~
- [ ] also not real
~~~
- [ ] real again
";
assert_eq!(vec!["real", "real again"], texts(body));
}
#[test]
fn an_indented_code_block_is_not_a_checklist() {
let body = "\
Write the parts like this:
- [ ] an example, not an item
- [ ] real
- [ ] nested
- plain bullet
- [ ] nested under a bullet
";
assert_eq!(vec!["real", "nested", "nested under a bullet"], texts(body));
}
#[test]
fn code_indented_inside_a_list_item_is_still_code() {
let body = "\
- outer
- [ ] an example, not an item
- [ ] nested
- plain
- [ ] nested under a bullet
- [ ] and under that one
";
assert_eq!(
vec!["nested", "nested under a bullet", "and under that one"],
texts(body)
);
}
#[test]
fn an_item_inside_raw_html_is_not_one() {
let body = "\
- [ ] real
<pre>
- [ ] not real
</pre>
<textarea>
- [ ] also not real
</textarea>
- [ ] real again
";
assert_eq!(vec!["real", "real again"], texts(body));
}
#[test]
fn an_item_inside_a_block_tag_is_not_one() {
let body = "\
<div>
- [ ] not real
</div>
<details>
<summary>the parts</summary>
- [ ] real
</details>
";
assert_eq!(vec!["real"], texts(body));
}
#[test]
fn a_checkbox_pushed_past_its_own_content_column_is_code() {
assert_eq!(Vec::<String>::new(), texts("- [ ] an example\n"));
assert_eq!(vec!["real"], texts("- [ ] real\n"));
}
#[test]
fn a_fence_indented_into_code_neither_opens_nor_closes() {
let body = "```\n- [ ] not real\n ```\n- [ ] still not real\n";
assert_eq!(Vec::<String>::new(), texts(body));
let body = "Like this:\n\n ```\n- [ ] real\n";
assert_eq!(vec!["real"], texts(body));
}
#[test]
fn an_item_inside_an_html_comment_is_not_one() {
let body = "\
- [ ] real
<!--
- [ ] not real
-->
- [ ] real again
<!-- - [ ] on one line, closed -->
- [ ] last
";
assert_eq!(vec!["real", "real again", "last"], texts(body));
}
#[test]
fn a_fence_is_closed_only_by_its_own_kind() {
let body = "~~~\n```\n- [ ] not real\n```\n~~~\n- [ ] real\n";
assert_eq!(vec!["real"], texts(body));
}
#[test]
fn a_windows_body_is_read_the_same_way() {
let items = parse("intro\r\n\r\n- [ ] first\r\n- [x] second\r\n");
assert_eq!(2, items.len());
assert_eq!("first", items[0].text);
assert!(items[1].checked);
assert_eq!(
"- [ ] first", items[0].raw,
"the terminator is not part of the handle"
);
}
#[test]
fn a_reference_is_read_from_a_number_or_a_link() {
let items = parse(
"- [ ] one #12\n\
- [ ] two https://github.com/o/r/issues/34\n\
- [ ] [three](https://github.com/o/r/issues/56)\n\
- [ ] four\n",
);
assert_eq!(Some(12), items[0].reference.as_ref().map(|r| r.number));
assert_eq!(Some(34), items[1].reference.as_ref().map(|r| r.number));
assert_eq!(Some(56), items[2].reference.as_ref().map(|r| r.number));
assert_eq!(None, items[3].reference);
}
#[test]
fn a_link_to_a_pull_request_is_a_reference_too() {
let items = parse(
"- [ ] one https://github.com/me/mine/pull/42\n\
- [ ] two https://github.com/me/mine/pull/43/files\n\
- [ ] three https://github.com/other/thing/pull/44\n",
);
assert_eq!(Some(42), items[0].reference.as_ref().unwrap().local(HOME));
assert_eq!(Some(43), items[1].reference.as_ref().unwrap().local(HOME));
assert_eq!(None, items[2].reference.as_ref().unwrap().local(HOME));
}
#[test]
fn an_item_that_is_a_link_to_something_else_names_no_issue() {
let items = parse("- [ ] [the docs](https://example.com/guide)\n");
assert_eq!(None, items[0].reference);
assert_eq!("[the docs](https://example.com/guide)", items[0].text);
}
#[test]
fn a_link_to_another_repository_is_not_adoptable() {
let items = parse("- [ ] see https://github.com/other/thing/issues/7\n");
let reference = items[0].reference.as_ref().expect("a reference");
assert_eq!(None, reference.local(HOME));
assert_eq!(Some(7), reference.local("https://github.com/other/thing"));
}
#[test]
fn a_bare_number_resolves_wherever_it_is_read() {
let items = parse("- [ ] work #7\n");
assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(""));
}
#[test]
fn a_link_to_the_same_path_on_another_host_is_not_this_repository() {
for url in [
"https://gitlab.example/me/mine/issues/7",
"https://github.com/mirror/me/mine/issues/7",
] {
let items = parse(&format!("- [ ] see {url}\n"));
let reference = items[0].reference.as_ref().expect("a reference");
assert_eq!(None, reference.local(HOME), "{url}");
}
}
#[test]
fn the_scheme_is_not_what_makes_a_link_somebody_elses() {
let items = parse("- [ ] see http://github.com/me/mine/issues/7\n");
assert_eq!(Some(7), items[0].reference.as_ref().unwrap().local(HOME));
}
#[test]
fn home_is_read_off_the_trackers_own_url() {
assert_eq!(HOME, home_of("https://github.com/me/mine/issues/29"));
assert_eq!("", home_of(""));
}
#[test]
fn a_number_in_a_code_span_names_nothing() {
let items = parse(
"- [ ] Handle the literal `#12`, tracked in #34\n\
- [ ] Only ``a #12 in a double span``\n",
);
assert_eq!(Some(34), items[0].reference.as_ref().map(|r| r.number));
assert_eq!(None, items[1].reference);
}
#[test]
fn a_number_in_a_comment_names_nothing() {
let items = parse(
"- [ ] ship it <!-- old note: #7 -->\n\
- [ ] and this one <!-- #7 --> #8\n",
);
assert_eq!(None, items[0].reference);
assert_eq!(Some(8), items[1].reference.as_ref().map(|r| r.number));
}
#[test]
fn a_fragment_in_a_link_is_not_an_issue_number() {
let items = parse(
"- [ ] update [docs](https://example.com/guide/#8)\n\
- [ ] see https://example.com/guide#9 and #10\n",
);
assert_eq!(None, items[0].reference);
assert_eq!(Some(10), items[1].reference.as_ref().map(|r| r.number));
}
#[test]
fn the_shorthands_github_links_are_references_too() {
let items = parse(
"- [ ] one me/mine#12\n\
- [ ] two other/thing#13\n\
- [ ] three GH-14\n",
);
assert_eq!(Some(12), items[0].reference.as_ref().unwrap().local(HOME));
let foreign = items[1].reference.as_ref().expect("a reference");
assert_eq!(None, foreign.local(HOME), "somebody else's repository");
assert_eq!("other/thing#13", foreign.names());
assert_eq!(Some(14), items[2].reference.as_ref().unwrap().local(HOME));
}
#[test]
fn a_shorthand_is_read_against_this_repositorys_path() {
let items = parse("- [ ] work me/mine#7\n");
let reference = items[0].reference.as_ref().expect("a reference");
assert_eq!(Some(7), reference.local("https://ghe.example/me/mine"));
assert_eq!(None, reference.local("https://github.com/me/other"));
assert_eq!(None, reference.local(""), "no address to measure against");
}
#[test]
fn a_link_is_read_from_its_destination_and_not_its_label() {
let items = parse(
"- [ ] [other/widgets #7](https://github.com/other/widgets/issues/7)\n\
- [ ] [me/mine #7](https://github.com/me/mine/issues/7)\n",
);
let foreign = items[0].reference.as_ref().expect("a reference");
assert!(
matches!(foreign.origin, Origin::Url(_)),
"the destination, not the label"
);
assert_eq!(None, foreign.local(HOME));
assert_eq!(Some(7), items[1].reference.as_ref().unwrap().local(HOME));
}
#[test]
fn complex_link_labels_still_read_the_destination() {
let items = parse(
"- [ ] [see [#7]](https://github.com/other/widgets/issues/8)\n\
- [ ] [see \\] #7](https://github.com/other/widgets/issues/8)\n",
);
for item in items {
let reference = item.reference.expect("the destination");
assert_eq!(8, reference.number);
assert!(matches!(reference.origin, Origin::Url(_)));
assert_eq!(None, reference.local(HOME));
}
}
#[test]
fn one_child_referenced_by_several_items_is_worked_once() {
assert_eq!(vec![8, 9], unique_children(vec![8, 8, 9, 8]));
}
#[test]
fn a_reference_is_appended_to_its_own_line_and_nowhere_else() {
let body = "intro\n\n- [ ] first\n- [ ] second\n\nmore prose\n";
let out =
rewrite(body, "- [ ] first", &Change::Reference("#40".into())).expect("a rewrite");
assert_eq!(
"intro\n\n- [ ] first #40\n- [ ] second\n\nmore prose\n",
out
);
}
#[test]
fn a_hard_break_survives_the_edit() {
let out = rewrite(
"- [ ] first \nnext\n",
"- [ ] first ",
&Change::Reference("#4".into()),
)
.expect("a rewrite");
assert_eq!("- [ ] first #4 \nnext\n", out);
}
#[test]
fn every_other_line_comes_through_byte_identical() {
let body = "# Plan\r\n\r\n trailing spaces here \r\n- [ ] one\r\n\r\n\r\n\r\nlots of blank lines above\r\n";
let out = rewrite(body, "- [ ] one", &Change::Reference("#9".into())).expect("a rewrite");
let (before, after): (Vec<&str>, Vec<&str>) =
(body.lines().collect(), out.lines().collect());
assert_eq!(before.len(), after.len());
for (i, (a, b)) in before.iter().zip(&after).enumerate() {
if i == 3 {
assert_eq!("- [ ] one #9", *b);
} else {
assert_eq!(a, b, "line {} changed", i + 1);
}
}
assert!(out.contains("trailing spaces here \r\n"));
assert!(out.contains("\r\n\r\n\r\n\r\n"));
}
#[test]
fn a_body_with_no_final_newline_keeps_not_having_one() {
let out = rewrite("- [ ] only", "- [ ] only", &Change::Tick).expect("a rewrite");
assert_eq!("- [x] only", out);
}
#[test]
fn ticking_changes_the_box_and_leaves_the_text() {
let out = rewrite(" - [ ] deep #3\n", " - [ ] deep #3", &Change::Tick).expect("a tick");
assert_eq!(" - [x] deep #3\n", out);
}
#[test]
fn a_ticked_box_is_never_written_again() {
assert!(rewrite("- [x] done\n", "- [x] done", &Change::Tick).is_err());
assert!(!matches!(Change::Tick, Change::Reference(_)));
}
#[test]
fn a_line_that_is_gone_or_ambiguous_is_a_refusal_not_a_guess() {
assert!(rewrite("- [ ] a\n", "- [ ] b", &Change::Tick).is_err());
let twice = "- [ ] same\n- [ ] same\n";
assert!(rewrite(twice, "- [ ] same", &Change::Tick).is_err());
}
#[test]
fn an_item_with_no_text_gets_no_reference() {
assert!(rewrite("- [ ]\n", "- [ ]", &Change::Reference("#1".into())).is_err());
}
fn shapes(body: &str, max: usize) -> Vec<Shape> {
shape(body, HOME, max).into_iter().map(|(_, s)| s).collect()
}
#[test]
fn a_checked_item_is_never_reconsidered() {
assert!(shapes("- [x] done\n", 5).is_empty());
}
#[test]
fn an_item_that_names_an_issue_is_kept_apart_from_one_that_does_not() {
assert_eq!(
vec![Shape::Names(12), Shape::Needs],
shapes("- [ ] one #12\n- [ ] two\n", 5)
);
}
#[test]
fn the_cap_stops_at_the_cap() {
let body = "- [ ] a\n- [ ] b\n- [ ] c\n- [ ] d\n";
assert_eq!(
vec![Shape::Needs, Shape::Needs, Shape::Over, Shape::Over],
shapes(body, 2)
);
}
#[test]
fn the_cap_counts_only_what_it_acts_on() {
let body = "- [x] a\n- [x] b\n- [ ] c\n";
assert_eq!(vec![Shape::Needs], shapes(body, 1));
}
#[test]
fn two_identical_items_are_left_alone() {
let out = shapes("- [ ] same\n- [ ] same\n", 5);
assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
assert!(matches!(out[1], Shape::Hold(_)), "{out:?}");
}
#[test]
fn an_item_naming_another_repository_is_held_rather_than_adopted() {
let out = shapes("- [ ] see https://github.com/other/thing/issues/7\n", 5);
assert!(matches!(out[0], Shape::Hold(_)), "{out:?}");
}
#[test]
fn a_realistic_tracker_keeps_every_line_it_was_not_asked_to_change() {
let body = "\
Context somebody wrote, with a hard break here:
and the rest of it.
## Parts
- [x] already done
- [ ] parse the checklist
- [ ] write the link back #40
- [ ] and prove it first
```markdown
- [ ] an example, not an item
```
That is all.
";
let shapes: Vec<Shape> = shape(body, HOME, 5).into_iter().map(|(_, s)| s).collect();
assert_eq!(
vec![Shape::Needs, Shape::Names(40), Shape::Needs],
shapes,
"the ticked item, the fenced one and the prose are all left out"
);
let out = rewrite(
body,
"- [ ] parse the checklist",
&Change::Reference("#41".into()),
)
.expect("a link");
let out = rewrite(
&out,
" - [ ] and prove it first",
&Change::Reference("#42".into()),
)
.expect("a nested link");
let out = rewrite(&out, "- [ ] write the link back #40", &Change::Tick).expect("a tick");
assert_eq!(
"\
Context somebody wrote, with a hard break here:
and the rest of it.
## Parts
- [x] already done
- [ ] parse the checklist #41
- [x] write the link back #40
- [ ] and prove it first #42
```markdown
- [ ] an example, not an item
```
That is all.
",
out
);
}
#[test]
fn an_item_linked_by_similarity_is_not_ticked_in_the_same_run() {
for open in [true, false] {
let action = Action::Link {
number: 7,
title: "something close enough".into(),
open,
};
assert_eq!(Some(Change::Reference("#7".into())), action.change());
}
}
#[test]
fn an_item_that_already_named_its_issue_is_ticked_when_that_issue_closes() {
assert_eq!(Some(Change::Tick), Action::Tick(7).change());
}
#[test]
fn nothing_is_written_for_an_item_that_is_already_linked_and_open() {
assert_eq!(None, Action::Adopt(7).change());
assert_eq!(None, Action::Over.change());
assert_eq!(None, Action::Hold("any reason".into()).change());
}
#[test]
fn a_line_that_stopped_being_an_item_is_not_written_to() {
let raw = "- [ ] ship it";
assert!(still_an_item("intro\n\n- [ ] ship it\n", raw));
assert!(!still_an_item("```\n- [ ] ship it\n```\n", raw));
assert!(!still_an_item("<!--\n- [ ] ship it\n-->\n", raw));
assert!(!still_an_item("- [ ] something else\n", raw));
assert!(
!still_an_item("- [ ] ship it\n- [ ] ship it\n", raw),
"two alike is a line the edit could go to either of"
);
}
#[test]
fn the_diff_shows_only_the_lines_that_change() {
let before = "- [ ] one\n- [ ] two\n";
let after =
rewrite(before, "- [ ] two", &Change::Reference("#8".into())).expect("a rewrite");
assert_eq!(
vec!["- - [ ] two".to_string(), "+ - [ ] two #8".to_string()],
diff(before, &after)
);
}
}