Syntax Highlighting in SwiftUI
If you ever need to show source code in your Mac, iOS, or iPad OS application, this might be useful for you.
I have written several articles so far. There are a lot of code blocks in those articles. In the earlier days, only keywords are set to heavier font-weight (bold). Later on, it is changed to a colourful code block.
I’m wondering how would I display code block in an iOS or macOS application.
The quick and dirty way is setting the font to Courier or other monospaced fonts against a Text
or UILabel
element. Yes, it works but it is like a bowl of alphabet soup.
The next step is syntax highlighting!
So I start creating a SwiftUI reference library application. (Some people call it a kitchen sink application.) The application demonstrates various SwiftUI features with sample source code.
Here is a screenshot of the application that I have been working on.
On the left, it shows some UI. Click on the info icon and a panel pops up. The panel contains some information with syntax highlighted source code.
The First Cut
First, I’m going to create a SwiftUI View
called SampleCodeView
.
struct SampleCodeView: View {
var sampleCode: String
var body: some View {
Text(sampleCode)
}
}
struct SampleCodeView_Previews: PreviewProvider {
static let smallBlock = #"""
if true {
Text("Hello World")
}
"""#
static let sampleSourceCode = smallBlock
static var previews: some View {
ScrollView {
SampleCodeView(sampleCode: sampleSourceCode)
}
}
}
I’m going to apply a monospaced font to make it a bowl of alphabet soup. 🙂
Instead of going for the traditional Courier or “Courier New” font, I decided to use a font called Menlo.
BTW, there are different sets of fonts that are preloaded in iOS and Mac. You should know which font is available in which environment. You can refer to this Apple document for available fonts.
After applying a new font and changing the sample code with a bigger block of code, this is what it looks like in the simulator.
Long lines wrap around in the view.
Let’s “unwrap” the code and see what will happen.
Text(sampleCode)
.fixedSize(horizontal: true, vertical: true)
.font(.custom("Menlo", size: 15))
It unwraps the long lines but only shows the code's middle part.
The .fixedSize() function indicates that the current UI element will use the “ideal” size instead of the size suggested by its parent element. (Apple’s documentation has a better explanation.)
In other words, the application will show the middle portion of the code block. The rest of the code block goes beyond the screen.
The code block cannot scroll/slide left and right across the view.
The Text
element needs to wrap inside a ScrollView
component. The ScrollView
needs to be set for horizontal scrolling.
ScrollView(.horizontal) {
Text(swiftCode)
.fixedSize(horizontal: true, vertical: true)
.font(.custom("Menlo", size: 15))
}
Functionally, it is good. I can scroll left and right to view the complete code.
Boxing Day
The code block should stand out from the rest of the content on the page. So, I’m going to mimic the appearance of a code block in this article.
ScrollView(.horizontal) {
Text(swiftCode)
.fixedSize(horizontal: true, vertical: false)
.font(.custom("Menlo", size: 15))
}
.padding(10)
.background(.yellow)
.border(.red, width: 2)
.cornerRadius(10)
For the time being, let’s make the background yellow and the border red with a corner radius of 10 points. It is a bit easier to see the result.
Instead of hard coding a specific color, you should use a named color. That way you can support dark mode in your application.
It seems like the border is broken at all four corners.
It seems like the .cornerRadius()
is simply removing or clipping the pixels from the underlining element. Well, it actually makes sense since it is a chain of UI operations.
If you move the .cornerRadius()
modifier ahead of both .background()
and .border()
modifiers, you won’t see the rounded corner effect.
Instead of invoking the .border()
modifier, perhaps we can overlay a RoundedRectangle
on top of the ScrollView.
ScrollView(.horizontal) {
Text(swiftCode)
.fixedSize(horizontal: true, vertical: false)
.font(.custom("Menlo", size: 15))
}
.padding(10)
.background(.yellow)
.cornerRadius(10)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.red, lineWidth: 2)
)
Vola! It works.
I also found another way to implement the rounded corner box. It uses a ZStack
.
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.yellow)
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(.red, lineWidth: 2)
ScrollView(.horizontal) {
Text(swiftCode)
.fixedSize(horizontal: true, vertical: false)
.font(.custom("Menlo", size: 15))
}
.padding(10)
}
It composes of three layers of objects: A rounded rectangle with background fill, a rounded rectangle with a line stroke (border), and the ScrollView
with the source code.
There might be other and better ways to implement this. Feel free to share it in the comment!
Syntax Highlighting Requirements
The solution that I’m going to implement will highlight the following:
- Swift keywords
- Class and struct name
- Property wrappers
Swift keywords can be found in the language reference manual. It is a finite set of words.
As for class and struct name, it is usually in pascal-case formattings, such as AttributedString
, and NSMutableAttributedString
.
Property wrappers are also in pascal-case format. It starts with an “@” character.
This is only a small set of requirements. You can add operators, quotation marks, brace brackets, and so on.
Thinking Process
What is it like to format some code in SwiftUI?
Technically, I should break down the code into words and spaces. Analyze each word and apply an appropriate style.
👉 A sample code snippet at Gist
Writing a parser is a bit overkill, I think.
Instead, I’m going to use regular expressions to search and apply a style.
Initialization
Define Regular Expression
Based on the criteria that I defined above, I have two regular expressions:
- Struct and class name, and property wrapper
- Swift keywords
The first one is easy. It is any word-bounded text with an optional leading “@” character.
/\u0040?\b[A-Z]w+\b/
The \u0040
is a Unicode for the “@” character.
For Swift keywords, I started by iterating through each keyword, compiling a regular expression for each keyword, and executing against the source code. Apply style against each matching result.
That doesn’t sound very efficient. There are about 100 keywords in Swift. So it needs to iterate through 100 times.
A better solution is combining all keywords into a single regular expression.
/\bassociatedtype\b|\bclass\b|\bdeinit\b|..../
The “\b” is a word-boundary token/metacharacter.
I copied all the keywords from Apple’s documentation, and I placed them in separate arrays based on the category specified in the documentation. (Keywords are under the “Keywords and Punctuation” section of the documentation.)
In order to create a string of regular expression pattern, we need to transform, map()
, and concatenate, joined()
, all the keywords together.
For simplicity, the sample code only supports Swift keywords. You can extend it to support other programming languages.
Search Set
A search set defines what the code should look for in the source code, and how to style the matches.
The structure contains an optional array of words to be sought, an uncompiled regular expression, and a (foreground) colour.
struct SearchSet {
let words: [String]?
let regexPattern: (String) -> String
let color: Color
init(words: [String]? = nil,
regexPattern: @escaping (String) -> String,
color: Color) {
self.words = words ?? [""]
self.regexPattern = regexPattern
self.color = color
}
}
You can modify it to support font weight and other font formatting features. Replace the color
with one of the following declarations:
// For AttributedString
let style: AttributeContainer
// For NSAttributedString
let style: [NSAttributedString.Key: Any]
If you do so, you also need to update the functions below.
The words
property comes from the original implementation. It can still be used to highlight anything else that you like. Here is an example:
SearchSet(words: ["apple", "orange", "rasin"],
regexPattern: { word in "\\b\(word)\\b" },
color: Color.purple)
Another example would be highlighting operators:
SearchSet(regexPattern: { word in "\\s[\\+\\-\\*\\/]\\s|\\s->\\s" },
color: Color.orange)
👉 In the playground project, there is a
SearchSetD
ata protocol. It contains syntax highlightingSearchSet
for Swift programming language.
Define CodeBlockView
This is where we define the output layout and the syntax highlighting logic.
Here is the first incarnation of the CodeBlockView
init()
function. It has a code
argument. This is where the application passes the raw source code.
The init()
function also defines the search sets.
struct CodeBlockView: View {
private let code: String
private var searchSets: [SearchSet] = []
init(code: String) {
self.code = code
// Keywords array (too long to show)
// Define search sets
self.searchSets = [
SearchSet(regexPattern: { _ in "\\u0040?\\b[A-Z]\\w+\\b"},
color: Color(red: 0.196, green: 0.392, blue: 0.659)),
SearchSet(regexPattern: { _ in "\(swiftKeywords)" },
color: Color(red: 0.615, green: 0.196, blue: 0.659))
]
}
var body: some View {
Text("TBD")
}
}
In the playground project, the searchSets
is refactored out into SearchSetData
. That makes the init()
cleaner and able to support other programming languages in the future.
Define a MasterPageView
to use the CodeBlockView
.
struct Sample {
private static let dataset1 = #"""
struct SearchSet {
let words: [String]?
let regexPattern: (String) -> String
let color: Color
init(words: [String]? = nil,
regexPattern: @escaping (String) -> String,
color: Color) {
self.words = words ?? [""]
self.regexPattern = regexPattern
self.color = color
}
}
"""#
static let data = dataset1
}
struct MasterPageView: View {
var body: some View {
VStack(alignment: .leading) {
CodeBlockView(code: Sample.data)
}
}
}
Define The CodeBlockView Body
The CodeBlockView
body
will use the “box” that we defined earlier in this article.
let borderColor = Color(red: 0.5, green: 0.5, blue: 0.5)
let bgColor = Color(red: 0.9, green: 0.9, blue: 0.9)
var body: some View {
VStack(alignment: .leading) {
ScrollView(.horizontal) {
Text(syntaxHighlight(self.code))
.fixedSize(horizontal: true, vertical: true)
.font(.custom("Menlo", size: 12))
}
.padding(10)
}
.background(bgColor)
.cornerRadius(5)
.overlay(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(borderColor, lineWidth: 2)
)
}
The content of the Text
component invokes a function called syntaxHighlight()
. It returns an instance of AttributedString
.
Highlight The Code
The process is straightforward. Compile and execute each set of a regular expression against the given raw source code. For every matching result, apply style against the attributed source code.
Compile Search Set Regular Expression
/// Syntax highlight the source code
/// - Parameter code: Source code
/// - Returns: Syntax highlighted source code
func syntaxHighlight(_ code: String) -> AttributedString {
var attrInText: AttributedString = AttributedString(code)
searchSets.forEach { searchSet in
searchSet.words?.forEach({ word in
guard let regex = try? Regex<Substring>(searchSet.regexPattern(word))
else {
fatalError("Failed to create regular expession")
}
processMatches(attributedText: &attrInText,
regex: regex,
color: searchSet.color)
})
}
return attrInText
}
The first loop iterates through the searchSets
. The second loop iterates through each word in the searchSet.word
.
Remember the init()
method sets the self.words = words ?? [“”]
. So the second loop is guaranteed to have one iteration.
guard let regex = try? Regex<Substring>(searchSet.regexPattern(word))
else {
fatalError("Failed to create regular expession")
}
The guard let
statement tries to compile the regular expression pattern stored in the searchSet
. The application will raise an exception and terminate. You can replace it with continue
and move on.
There are a lot of online regular expression testers. It is a good idea to test your regular expression before adding it to your code.
Apply Style
This part is a bit tricky.
Now, we have a compiled regular expression. We need to use it to find all occurrences within the source code.
AttributedString
doesn’t seem to have a method to perform regular expression operations. (If there is one, please let me know.)
However, String
has a matches(of:)
instance method that accepts a regular expression and returns an array of matches.
In the original implementation, I pass the source code in both String
and AttributedString
to the processMatches()
function.
private func processMatches(originalText: String,
attributedText: inout AttributedString,
regex: Regex<Substring>,
color: Color) {
...
}
The raw String
version is used for pattern matching and the AttributedString
version is for styling.
Ideally, I should be able to extract the raw text from the attributedText
. However, I have a hard time finding an equivalent NSAttributedString.string
property in AttributedString
. (Again, if there is one, please let me know.)
So the workaround is reconstructing the source code from the attributedText
.
var origialText: String = (
attributedText.characters.compactMap { c in
String(c)
} as [String]).joined()
Since I can reconstruct the raw source code therefore I don’t need to pass it from the calling function.
// Revised function signature
private func processMatches(attributedText: inout AttributedString,
regex: Regex<Substring>,
color: Color) {
...
}
Note that the attributedText
argument has an inout
keyword. That is because the function will directly apply the style in the attributedText
.
Now I can search and apply style against the attributedText
.
The String.matches(of:)
will return an array of matches or an empty array. A forEach
loop can iterate through each match.
orignalText.matches(of: regex).forEach { match in
attributedText[match.range][
AttributeScopes
.SwiftUIAttributes.ForegroundColorAttribute.self
] = color
}
That causes an exception!
What it means is match.range
has the type Range<String.Index>
rather than Range<AttributedString.Index>
.
So we need to create a new Range based on match.range
with respect to attributedText
.
if let swiftRange = Range(match.range, in: attributedText) {
...
}
Here is the complete processMatches()
function:
/// Apply style for each matching word
/// - Parameters:
/// - attributedText: Attributed text
/// - regex: Compiled regular expression
/// - color: Color style
private func processMatches(attributedText: inout AttributedString,
regex: Regex<Substring>,
color: Color) {
var origialText: String = (
attributedText.characters.compactMap { c in
String(c)
} as [String]).joined()
orignalText.matches(of: regex).forEach { match in
if let swiftRange = Range(match.range, in: attributedText) {
attributedText[swiftRange][
AttributeScopes
.SwiftUIAttributes.ForegroundColorAttribute.self
] = color
}
}
}
The foreground colour assignment can shorten to…
attributedText[swiftRange].foregroundColor = UIColor(color)
Apparently, .foregroundColor
property and key refer to AttributeScopes.UIKitAttribute.ForegroundColor
! So it needs to convert SwiftUI Color
to UIColor
.
Slightly Older Way
So far the code above runs in the latest O/S. i.e. iOS 16
What if I want to run it in iOS 15? It won’t have String.matches(of:)
and Regex
.
Fortunately, we can use NSRegularExpression
.
Let’s go through this simple exercise.
Change Regex to NSRegularExpression
In the syntaxHighlight()
function, the guard
statement is updated with NSRegularExpression
.
// From
guard let regex = try? Regex<Substring>(searchSet.regexPattern(word))
else {
fatalError("Failed to create regular expession")
}
// To
guard let regex = try? NSRegularExpression(pattern:searchSet.regexPattern(word))
else {
fatalError("Failed to create regular expression: \(word)")
}
The processMatches()
function signature needs to be updated as well:
private func processMatches(attributedText: inout AttributedString,
regex: NSRegularExpression,
color: Color) {
...
}
The regex
argument data type becomes NSRegularExpression
.
We still need to extract the original text from the attributedText
.
Regular expression searches with NSRegularExpression
is slightly different from the Regex
version. The raw source code, originalText
, needs to pass into regex.enumerateMatches()
function.
let ofRange = NSRange(location: 0, length: originalText.count)
regex.enumerateMatches(in: originalText, range: ofRange) { (match, _, stop) in
guard let match = match else { return }
if let swiftRange = Range(match.range, in: attributedText) {
attributedText[swiftRange].foregroundColor = UIColor(color)
}
}
The .enumerateMatches()
function not only needs the source string. It also needs a range, NSRange
, within the given text string.
You can also use NSMakeRange
or another NSRange
initializer to create a NSRange
.
let ofRange = NSMakeRange(0, originalText.count)
let ofRange = NSRange(originalText.startIndex..<originalText.endIndex,
in: originalText)
The match
argument in the closure is nullable. As a result, I need to use a guard
statement.
Finally, apply the style against the attributedText
. Just like before.
👉 Here is the complete NSRegularExpression version of the code
Two More Things
Highlight Rows
Hypothetically, I can specify a single line or a range of lines. Probably something like this:
var rows = [
Highlight(2), Highlight(rangeOf: 7...9), Highlight(14)
]
So the Highlight
structure needs to be able to initialize with a single value or a range. Here is the implementation:
struct Highlight: Identifiable {
let id = UUID()
private var lines: [Int] = []
var values: [Int] {
lines
}
init(_ lineNumber: Int) {
self.lines.append(lineNumber)
}
init(rangeOf: ClosedRange<Int>) {
for i in rangeOf {
self.lines.append(i)
}
}
}
Internally, it stores line numbers in an array of Int
.
The first init()
function stores a single value in the array.
The second init()
function accepts a range of integers, in particular, a ClosedRange
of Int
type, through a range operator. The function iterates through each value and appends to the array.
The CodeBlockView
initialization needs to be modified a bit.
In addition to passing the entire code block to the init()
function, it needs to know which row or rows to highlight.
struct CodeBlockView: View {
private let codeLines: Array<Substring>
private let highlightRows: Set<Int>
...
init(code: String, highlightAt: [Highlight]? = nil) {
// Split the codes into individual lines
self.codeLines = code.split(separator: /\n/)
// Collect all highlight row numbers
var rows: Set<Int> = []
if let lineNumbers = highlightAt {
lineNumbers.forEach { group in
group.values.forEach { row in
rows.insert(row)
}
}
}
self.highlightRows = rows
...
}
...
}
The highlightAt
argument is an array of Highlight
, and it is optional.
Instead of storing the entire code block, the new init()
split the code block into individual lines of code.
Apparently,
code.split()
returnArray<Substring>
instead ofArray<String>
highlightRows
is a Set<Int>
. It holds row numbers to be highlighted. Why Set<Int>
?
A Swift Set is “an unordered collection of unique elements.” It is ideal to store unique row numbers. There is no need to have duplicate row numbers!
The only change left is in the output:
var body: some View {
VStack(alignment: .leading) {
ScrollView(.horizontal) {
VStack(alignment: .leading, spacing: 0) {
ForEach(codeLines.indices, id: \.self) { idx in
Text(syntaxHighlight(String(codeLines[idx])))
.frame(maxWidth: .infinity, alignment: .leading)
.font(.custom("Menlo", size: 12))
.background(highlightRows.contains(idx+1) ? .yellow : Color.clear)
}
}
}
.padding(10)
}
.background(bgColor)
.cornerRadius(5)
.overlay(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(borderColor, lineWidth: 2)
)
}
There are quite a few changes in the body
code.
Instead of performing syntax highlighting against the entire code block, it will become a per-line basis.
VStack(spacing: 0) {
ForEach(codeLines.indices, id: \.self) { idx in
...
}
}
The ForEach
loop iterates through the codeLines
indices. The index value will be used for row highlighting.
In the earlier implementation, the Text
element uses .fixedSize()
modifer to force unwrapping the code block. It works great for the entire code block. For the individual lines of code, it doesn’t work perfectly.
To make it easier to understand, I purposefully set a red border for the following screenshots.
.fixedSize()
sets the frame width that fits the content. Hence, the ideal width.
.frame(maxWidth: .infinity, alignment: .leading)
sets the frame as large as the parent element. (maxWidth: .infinity
doesn’t mean it goes to the edge of the universe! 😀) Also, alignment: .leading
will be left justified content within the frame.
Lastly, the logic sets the background colour if the index+1 is found in the highlightRows
. Otherwise, there is no background colour, Color.clear
.
.background(highlightRows.contains(idx+1) ? .yellow : Color.clear)
This is what it looks like:
Line Numbering
Since we are able to highlight rows therefore we can also add line numbers.
First, we are going to add a boolean argument to the CodeBlockView
init()
function. It will be used to toggle the line number column.
struct CodeBlockView: View {
private let codeLines: Array<Substring>
private let highlightRows: Set<Int>
private let showLineNumber: Bool
...
init(code: String,
highlightAt: [Highlight]? = nil,
showLineNumbers: Bool = false) {
...
self.showLineNumber = showLineNumbers
...
}
...
}
Next, we need to add the line number column.
Here are a couple of layout approaches:
The white text with a blue background Text
element is the line number. The other one is the syntax highlighted source code.
Both layout structures have the correct appearance but they have different behaviour.
The line number column in the left layout structure will scroll off the view.
Here is the code snippet:
struct CodeBlockView: View, SearchSetData {
....
private let lineNumberColumnWidth: CGFloat
....
init(....) {
....
self.lineNumberColumnWidth = String(self.codeLines.count)
.size(withAttributes: [.font: UIFont(name: "Menlo", size: 12)!])
.width
.rounded(.up)
}
var body = some View {
VStack(alignment: .leading) {
ScrollView(.horizontal) {
VStack(alignment: .leading, spacing: 0) {
ForEach(codeLines.indices, id: \.self) { idx in
HStack(spacing: 2) {
Text("\(idx+1)")
.frame(width: self.lineNumberColumnWidth,
alignment: .trailing)
.font(.custom("Menlo", size: 12))
.foregroundColor(.white)
.padding(EdgeInsets(top: 0, leading: 2, bottom: 0, trailing: 2))
.background(.blue)
Text(syntaxHighlight(String(codeLines[idx])))
.frame(maxWidth: .infinity, alignment: .leading)
.font(.custom("Menlo", size: 12))
.background(highlightRows.contains(idx+1) ? .yellow : Color.clear)
}
}
}
}
.padding(10)
}
.background(bgColor)
.cornerRadius(5)
.overlay(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(borderColor, lineWidth: 2)
)
}
}
In order to keep the line number Text
element has the same width among all rows, the init()
function needs to calculate the width based on the number of lines in the code block.
It is pretty straightforward. First, stringify the number of rows, codeLines.count
. Get the size of the string with respect to the targetted font and font size. Finally, get the width
from the resulting size
modifier. In the code above, there is a .round(.up)
. It is optional because frame(width)
is a CGFloat
type.
The second approach is a two-columns layout. The left column is the line numbers, and the right column is the source code. A HStack
will hold the columns side-by-side.
Here is the layout code:
var body: some View {
VStack(alignment: .leading) {
HStack(spacing: 5) {
// Row number column
if (self.showLineNumber) {
VStack(alignment: .trailing) {
ForEach(codeLines.indices, id: \.self) { idx in
Text("\(idx+1)")
.font(.custom("Menlo", size: 12))
.foregroundColor(.white)
.padding(EdgeInsets(top: 0, leading: 2, bottom: 0, trailing: 2))
}
}
.background(.blue)
}
// Source code column
ScrollView(.horizontal) {
VStack {
ForEach(codeLines.indices, id: \.self) { idx in
Text(syntaxHighlight(String(codeLines[idx])))
.frame(maxWidth: .infinity, alignment: .leading)
.font(.custom("Menlo", size: 12))
.background(highlightRows.contains(idx+1) ? .yellow : Color.clear)
}
}
}
}
.padding(10)
}
.background(bgColor)
.cornerRadius(5)
.overlay(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(borderColor, lineWidth: 2)
)
}
Just to make it a little bit more efficient. If the application only needs to show a syntax-highlighted source code, there is no need to split the raw source code into individual lines.
init(code: String,
highlightAt: [Highlight]? = nil,
showLineNumbers: Bool = false) {
....
if !showLineNumbers && rows.count == 0 {
// Not going to show line numbers and no row highlighting
// Create a single element array
self.codeLines = [Substring(code)]
} else {
// Split the codes into individual lines
self.codeLines = code.split(separator: /\n/)
}
}
SwiftUI Regex and NSRegularExpression
NSRegularExpression
conform to the International Components for Unicode (ICU) specification for regular expressions.
Although there is no mention of such specifications for Regex
, I can’t imagine that they have a different implementation.
With Regex
, there is a new framework called RegexBuilder. According to Apple’s documentation, RegexBuilder “Uses an expressive domain-specific language to build regular expressions, for operations like searching and replacing in text.” It is verbose and it is an interesting way to build regular expression patterns.
There is also an online ICU regular expression tester. It might be useful for testing complicated queries.
References
I hope you enjoy this article.
Feel free to leave me comments and suggestions.