Kieran's Components Logo

Kieran's Components

Explore the world of UI


Xcode Source Editor

21 Apr 2020





Suggestions

After adding the Extension target to a project

  1. Go to the menu bar -> Product -> Scheme -> Edit Scheme.
  2. Under the info tab change the executable dropdown menu to “Xcode.app”.
  3. If you have a testing file add it to the “Arguments Passed On Launch” field under the Arguments tab.

Important XcodeKit Constructs

XCSourceEditorCommandInvocation - Provides access to a type called buffer

Buffer

How To Use

  1. Either use the SourceEditorCommand class generated by Xcode or implement your own class that conforms to NSObject and XCSourceEditorCommand.
  2. Within the perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void implement the methods used to adjust the source code.

The starting inputs for manipulating the text document fall into three categories

  1. Single Points - Basically just a cursor or cursors positioned somewhere in the text.
  2. Selections - Highlighted strings of text.
  3. Full Document - All Lines of the current document.

Helpers Functions For Selections

This Array extension makes it so that the XCSourceTextRange is converted into a more convienent format for manipulating strings.

extension Array where Element == String {
    /// Given an Array of Strings and an XCSourceTextRange
    /// The range in terms of string indices is returned
    func getRanges(textRange: XCSourceTextRange) -> [Range<String.Index>] {
        // Case 1: Single line
        if textRange.start.line == textRange.end.line {
            let line = self[textRange.start.line]
            let range = line.index(line.startIndex, offsetBy: textRange.start.column)..<line.index(line.startIndex, offsetBy: textRange.end.column - textRange.start.column)
            return [range]
        // Case 2: Multiple Lines
        } else {
            let lines = self[(textRange.start.line)...(textRange.end.line)]
            let first = lines.first
            let firstRange = (first?.index(first!.startIndex, offsetBy: textRange.start.column))!..<first!.endIndex
            var ranges = [firstRange]
            // Case 2b: More than 2 lines
            if lines.count > 2 {
                let middle = lines.dropFirst().dropLast()
                for line in middle {
                    ranges.append(line.startIndex..<line.endIndex)
                }
            }
            let last = lines.last
            let lastRange = (last?.startIndex)!..<(last?.index(last!.startIndex, offsetBy: textRange.end.column))!
            ranges.append(lastRange)
            return ranges
        }
    }
}

The next method should be included within the SourceEditorCommand class. replaceSelections handles everything but the formatting of the selections to be replaced. It makes sure that operations which cause an addition or subtraction in the number of selected lines don’t overwrite or delete unselected text. Convienence to internally refer to this tuple as TextData typealias TextData = (line: Int, range: Range, text: String)

Segment of data used to represent a multi-line selection of text typealias Selection = [TextData]

Replace Selected Text

This function should be used only when formatting selected text. It will not format anything but the selected text. Works with multiple selections simultaneously or single selections.

func replaceSelected(buffer: XCSourceTextBuffer, formatter: @escaping (Selection) -> [String?]) {
    // 1. Get all lines and selected text from buffer
    guard let lines = buffer.lines as? [String] else { return }
    guard let selections = buffer.selections as? [XCSourceTextRange] else { return }
    // if an addition operation occurs the number of added lines will be recorded.
    var documentOffset = 0
    // contains all replacement data for the selection plus the difference between the number of unformatted lines and formatted lines.
    var replacements = [(diff: Int, selected: Selection)]()
    // 2. For every selection create arrays of replacement data.
    // This loop implies that all selections should be treated individually when performing replacements.
    for selection in selections {
        // The selected text data prior to formatting.
        var unformatted = Selection()
        // A. Creating the unformatted selection array.
        for (index, r) in lines.getRanges(textRange: selection).enumerated() {
            let line = lines[selection.start.line+index]
            let selected = line[r]
            unformatted.append((selection.start.line+index, r, String(selected)))
        }
        // Empty formatted array, and 0 current value.
        var formatted = Selection()
        var current = 0
        let startLine = unformatted.first?.line
        // B. Format and append textData to formatted
        // if the current line count is greater than or equal the number of unformatted lines
        // then append new data for the line number and range.
        formatter(unformatted).forEach { (new: String?) in
            current < unformatted.count ?
                formatted.append((unformatted[current].line, unformatted[current].range, new!)):
                formatted.append((current+startLine!, new!.startIndex..<new!.endIndex , new!))
            current += 1
        }
        // append the selection replacement data to replacements.
        replacements.append((formatted.count - unformatted.count, formatted))
    }
    // 3. Update the buffer with the replacement data.
    //    Makes adjustments by adding or removing lines at
    //    required indices.
    replacements.forEach { (diff: Int, selected: Selection) in
        let lastSelection = selected.last!.line + documentOffset
        // A. Add or Subtract Lines.
        if diff > 0 {
            // insert additional blank lines to be overridden by the added lines.
            for _ in 0..<diff { buffer.lines.insert("", at: lastSelection-diff+1)}
        } else if diff < 0 {
            // removes lines at the given index.
            for _ in diff..<0 { buffer.lines.removeObject(at: lastSelection)}
        }
        var lastLine = 0
        // B. Perform Replacement of Lines.
        selected.forEach {
            lastLine = $0.line + documentOffset
            let oldLine = lines[lastLine]
            // use the segment from the start of the old line to the selection point
            // then add the new text to this segment.
            let newLine = oldLine[..<$0.range.lowerBound] + $0.text
            buffer.lines[lastLine] = newLine
        }
        // update the offset to account for changes.
        documentOffset += diff
    }
}

Formatters

All That you need to implement for replacing selected text is the formatter. The formatter recieves a set of data about the text to be modifed in the form: [(line: Int, range: Range<String.Index>, text: String)] where

The array itself represents a single selected region of text spanning multiple lines.

example 1 - CaseFlip

For every character in a selected line of test check if this character is uppercase or lowercase then reverse the case upper -> lower lower -> upper

func caseFlip(_ input: Selection) -> [String?] {
    var replacements = [String?]()
    for text in input {
        var replacement: String = ""
        // For every character in the string check if upper or lower case then flip them upper -> lower and lower -> upper.
        text.text.forEach {
            var current = $0
            if $0.isUppercase {
                current = Character(current.lowercased())
            } else if $0.isLowercase {
                current = Character(current.uppercased())
            }
            replacement += String(current)
        }
        replacements.append(replacement)
    }
    return replacements
}

example 2 - CSV to String Literal Array

For this example two different functions have been created that both work with the same input but one outputs the array in a single line while the other creates multiple lines.

Single Line

func CSVtoArray(_ input: Selection) -> [String?] {
    var replacement = "let <# name #> = ["
    for line in input {
        line.text.split(separator: ",")
            .filter { !$0.isEmpty }
            .forEach {
                if !($0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) {
                    replacement += " \"\($0.trimmingCharacters(in: .whitespaces))\","
                }
        }
    }
    return [replacement.dropLast() + "]"]
}

Multiple Lines

func verticalCSVToArray(_ input: Selection) -> [String?] {
    let heading = "let <# name #> = ["
    var replacements = [heading]
    for line in input {
        line.text.split(separator: ",")
            .filter { !$0.isEmpty }
            .forEach {
                if !($0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) {
                    replacements.append("\"\($0.trimmingCharacters(in: .whitespaces))\",")
                }
        }
    }
    replacements[replacements.count-1] = String(replacements[replacements.count-1].dropLast())
    replacements.append("]")
    return replacements
}