Suggestions
After adding the Extension target to a project
- Go to the menu bar -> Product -> Scheme -> Edit Scheme.
- Under the info tab change the executable dropdown menu to “Xcode.app”.
- 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
- Either use the
SourceEditorCommand
class generated by Xcode or implement your own class that conforms toNSObject
andXCSourceEditorCommand
. - 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
- Single Points - Basically just a cursor or cursors positioned somewhere in the text.
- Selections - Highlighted strings of text.
- 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
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 theXCSourceEditorCommandInvocation
. - formatter: A function that uses the components of
Selection
to transform the original selected text into a new format.
- buffer: The
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
}