import Foundation /// The task lists in what someone wrote: `- [ ] a thing to do`. Its author ticks /// one by rewriting that mark. These are the tasks `web/src/lib/tasks.ts` finds, /// so a tick on either rewrites the same line, and never another. enum MarkdownTasks { /// A task's place in what's written: which it is, from 0, and on which line. struct Task: Hashable { let number: Int let line: Int let mark: Int let done: Bool } /// Each one in `text`, leaving out what's in fenced code. static func all(_ text: String) -> [Task] { var found: [Task] = [] var fence: [Character] = [] for (at, line) in text.components(separatedBy: "\n").enumerated() { let letters = Array(line) let opens = self.fence(letters) if !fence.isEmpty { if let opens, opens[0] == fence[0], opens.count >= fence.count { fence = [] } } else if let opens { fence = opens } else if let mark = self.mark(letters) { found.append(Task(number: found.count, line: at, mark: mark, done: letters[mark] != " ")) } } return found } /// `text` with its task `number` ticked or unticked, or nil with no such task. static func ticked(_ text: String, _ number: Int, done: Bool) -> String? { let found = all(text) guard found.indices.contains(number) else { return nil } let task = found[number] var lines = text.components(separatedBy: "\n") var letters = Array(lines[task.line]) letters[task.mark] = done ? "x" : " " lines[task.line] = String(letters) return lines.joined(separator: "\n") } /// What a task says, past its box. static func words(_ line: String, _ task: Task) -> String { String(line.dropFirst(task.mark + 2)).trimmingCharacters(in: .whitespaces) } private static func blank(_ c: Character) -> Bool { c == " " || c == "\t" } /// Past any quote marks and the space round them, as those lines can be nested. private static func pastQuotes(_ line: [Character]) -> Int { var at = 0 while true { var next = at while next < line.count, blank(line[next]) { next += 1 } guard next < line.count, line[next] == ">" else { return at } at = next + 1 } } /// The fence a line opens or closes: three or more of a backtick or tilde, /// under four spaces in. private static func fence(_ line: [Character]) -> [Character]? { var at = pastQuotes(line) var spaces = 0 while at < line.count, blank(line[at]), spaces < 3 { at += 1 spaces += 1 } guard at < line.count, line[at] == "`" || line[at] == "~" else { return nil } let run = line[at...].prefix { $0 == line[at] } return run.count >= 3 ? Array(run) : nil } /// Where a list item's box has its mark, if the line is one. private static func mark(_ line: [Character]) -> Int? { var at = pastQuotes(line) while at < line.count, blank(line[at]) { at += 1 } guard at < line.count else { return nil } if "-*+".contains(line[at]) { at += 1 } else { let digits = line[at...].prefix { $0.isASCII && $0.isNumber }.count guard (1...9).contains(digits), at + digits < line.count, ".)".contains(line[at + digits]) else { return nil } at += digits + 1 } let start = at while at < line.count, blank(line[at]) { at += 1 } guard at > start, at + 2 < line.count, line[at] == "[", line[at + 2] == "]", " xX".contains(line[at + 1]) else { return nil } guard at + 3 == line.count || blank(line[at + 3]) else { return nil } return at + 1 } }