Skip to content

Instantly share code, notes, and snippets.

@kieranb662
Last active April 6, 2023 15:44
Show Gist options
  • Save kieranb662/efe05dc7a9a8c8789bd00116c781998f to your computer and use it in GitHub Desktop.
Save kieranb662/efe05dc7a9a8c8789bd00116c781998f to your computer and use it in GitHub Desktop.
[Xcode Editor Notes] Xcodekit Extensions to help make Xcode your own custom text editor.

Xcode Editor Notes

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

  • selections - a value containing the start and end positions of selected text [XCSourceTextRange]
  • lines - an Array of lines contained in the buffer
  • completeBuffer - String containing all values in the 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<String.Index>, 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.
///
/// - Parameters:
///   - buffer: The `XCSourceTextBuffer` provided by the `XCSourceEditorCommandInvocation`.
///   - formatter: A function that uses the components of `Selection` to transform the original selected text into a new format.
///
/// - Requires:
///    The `getRanges` Array method.
///
/// - Important: Tested and works with `CSVToArray` and `verticalCSVToArray`
///
/// - Note: At Step 2. I would like to make an adjustment that allows for both the simultaneuous formatting of selections or
///         the individual formatting of selections. Currently only performs the latter.
///
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

  • line - is the number representation of the selected line
  • range - is the portion of the line that text represents
  • text - is selected text which will be formatted

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
}
@rob4226
Copy link

rob4226 commented Jan 28, 2023

This is really great stuff, thank you so much for posting!! 🚀

Just one correction for the getRanges function in the extension to Array. In 'Case 1: Single line' the line:

let range = line.index(line.startIndex, offsetBy: textRange.start.column)..<line.index(line.startIndex, offsetBy: textRange.end.column - textRange.start.column)

should be:

let range = line.index(line.startIndex, offsetBy: textRange.start.column)..<line.index(line.startIndex, offsetBy: textRange.end.column)

This removes - textRange.start.column because for the upperBound of the range, you want the actual ending index for the selection, not the length of the selection.

I saw this situation popup when selecting a single line comment that is indented. If the comment is not indented (starts at column 0 of the document) then the length equals the ending index so you wouldn't see a problem using the original code, but once you work with an indented comment then it becomes apparent that you really want the ending index of the selection for the upperBound of the range, not the length of the selection. It gets confusing because to get the ending index for the range, the index function takes an argument called offsetBy lol. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment