~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
95 lines · 3.8 KB Raw History
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
    }
}