Declarative Text Kit: Sketch of an API

I invested the past two weeks into making the Markdown highlighting component of my app The Archive better overall. More secure C token pointer handling, more performant token tree mutations, so faster highlighting and fewer pitfalls to worry about.

This was also a preparation to improve structural editing, like “select the whole list of which this item is a part of”. Or “wrap selection in a fenced code block”.

The fenced code block situation is a bit hairy, though, because I’d like to first remove all code blocks in the selected region of the text (which may expand the selection) and then wrap the resulting range of text in a new block. That sounds a bit weird on paper but makes sense when you see it in action and trigger the command to put the selection in a code block.

One (not yet functional, but compiling) API I sketched this morning is the following:

func wrapInFencedCodeBlock(selectedRange: NSRange) {
    within(range: selectedRange) { find in
        let codeBlocksInRange = find.all(FencedCodeBlock.self)

        Select(
            LineRange(covering: codeBlocksInRange) // nil when nothing found
                ?? LineRange(selectedRange)
        ) { affectedLineRange in
            // Remove all old code blocks in range
            Modifying(&affectedLineRange) {
                Delete(codeBlocksInRange.flatMap { $0.removingMarkup() })
            }

            // Wrap resulting text in code block
            Modifying(&affectedLineRange) { rangeToWrap in
                Insert(rangeToWrap.location) { Line("```") }
                Insert(rangeToWrap.endLocation) { Line("```") }
            }

            // Move insertion point to the position after the opening backticks
            Select(affectedLineRange.location + length(of: "```"))
        }
    }
}

This is just a non-functional demo. I’m quite happy with how it reads already.

I’m not yet certain whether this will cover upcoming formatting tasks. But I can imagine a few of the existing formatting actions to work well in this style, for example toggling bold emphasis:

func toggleBold(selectedRange: NSRange) {
    within(range: selectedRange) { find in
        let boldText = find.all(StrongEmphasis.self)
        
        if boldText.isEmpty {
            // Emphasise the selection at word boundaries
            Select(WordRange(selectedRange)) { selectedRange in
                Insert(selectedRange.location) { "**" }
                Insert(selectedRange.endLocation) { "**" }
            }
        } else {
            // Remove all strong emphasis pairs in range
            Select(WordRange(covering: boldText)) { affectedWordRange in
                Modifying(&affectedWordRange) {
                    Delete(boldText.flatMap { $0.removingMarkup() })
                }
            }
        }
    }
}

Currently, the action that ships with the app un-emphasises only when you select the whole **bold text** range (which the emboldening action does automatically). And that’s quite a cumbersome procedure already that checks for asterisks at the edges, removes them in reverse order (deleting back-to-front to not invalidate indices), or wraps the selection in two asterisks. Since it predates the AST-based parser, it also on the pure string contents decides (correctly!) which of triple markers like _**bold italic**_ to delete. It also inserts two underscores instead of asterisks, depending on user preference. (Deletion should work with both asterisks and underscores, of course.)

The declarative counterpart above looks so much better.

Next I’ll work on tests to perform the changes from the scenarios I just sketched (outside-in, Growing Object-Oriented Software Guided by Tests style), and then I’ll implement the engine to get there piecemeal.