From 9a486543e8ea44a705359cd06069c8f86e18df1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=2E=20St=C3=B8vring?= Date: Sat, 23 Mar 2024 13:55:15 +0100 Subject: [PATCH] Adds StringSyntaxHighlighter (#363) * Adds default tab stops * Fixes SwiftLint warning * Adds StringSyntaxHighlighter * Adds documentation * Fixes SwiftLint warnings * Improves formatting --- .../Documentation.docc/Documentation.md | 2 + .../Extensions/StringSyntaxHighlighter.md | 45 ++++++++ .../SyntaxHighlightingAString.md | 48 ++++++++ .../Runestone/StringSyntaxHighlighter.swift | 105 ++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md create mode 100644 Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md create mode 100644 Sources/Runestone/StringSyntaxHighlighter.swift diff --git a/Sources/Runestone/Documentation.docc/Documentation.md b/Sources/Runestone/Documentation.docc/Documentation.md index 058d6647a..4be7c7771 100644 --- a/Sources/Runestone/Documentation.docc/Documentation.md +++ b/Sources/Runestone/Documentation.docc/Documentation.md @@ -61,12 +61,14 @@ Syntax highlighting is based on GitHub's [Tree-sitter](https://github.com/tree-s - - +- - ``LanguageMode`` - ``PlainTextLanguageMode`` - ``TreeSitterLanguageMode`` - ``TreeSitterLanguage`` - ``TreeSitterLanguageProvider`` - ``SyntaxNode`` +- ``StringSyntaxHighlighter`` ### Indentation diff --git a/Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md b/Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md new file mode 100644 index 000000000..578b6528d --- /dev/null +++ b/Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md @@ -0,0 +1,45 @@ +# ``StringSyntaxHighlighter`` + +## Example + +Create a syntax highlighter by passing a theme and language, and then call the ``StringSyntaxHighlighter/syntaxHighlight(_:)`` method to syntax highlight the provided text. + +```swift +let syntaxHighlighter = StringSyntaxHighlighter( + theme: TomorrowTheme(), + language: .javaScript +) +let attributedString = syntaxHighlighter.syntaxHighlight( + """ + function fibonacci(num) { + if (num <= 1) { + return 1 + } + return fibonacci(num - 1) + fibonacci(num - 2) + } + """ +) +``` + +## Topics + +### Essentials + +- +- ``StringSyntaxHighlighter/syntaxHighlight(_:)`` + +### Initialing the Syntax Highlighter + +- ``StringSyntaxHighlighter/init(theme:language:languageProvider:)`` + +### Configuring the Appearance + +- ``StringSyntaxHighlighter/theme`` +- ``StringSyntaxHighlighter/kern`` +- ``StringSyntaxHighlighter/lineHeightMultiplier`` +- ``StringSyntaxHighlighter/tabLength`` + +### Specifying the Language + +- ``StringSyntaxHighlighter/language`` +- ``StringSyntaxHighlighter/languageProvider`` diff --git a/Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md b/Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md new file mode 100644 index 000000000..f812e0f58 --- /dev/null +++ b/Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md @@ -0,0 +1,48 @@ +# Syntax Highlighting a String + +Learn how to syntax hightlight a string without needing to create a TextView. + +## Overview + +The can be used to syntax highlight a string without needing to create a . + +Before reading this article, make sure that you have follow the guides on and . + + +## Creating an Attributed String + +Create an instance of by supplying the theme containing the colors and fonts to be used for syntax highlighting the text, as well as the language to use when parsing the text. + +```swift +let syntaxHighlighter = StringSyntaxHighlighter( + theme: TomorrowTheme(), + language: .javaScript +) +``` + +If the language has any embedded languages, you will need to pass an object conforming to , which provides the syntax highlighter with additional languages. + +Apply customizations to the syntax highlighter as needed. + +```swift +syntaxHighlighter.kern = 0.3 +syntaxHighlighter.lineHeightMultiplier = 1.2 +syntaxHighlighter.tabLength = 2 +``` + +With the syntax highlighter created and configured, we can syntax highlight the text. + +```swift +let attributedString = syntaxHighlighter.syntaxHighlight( + """ + function fibonacci(num) { + if (num <= 1) { + return 1 + } + return fibonacci(num - 1) + fibonacci(num - 2) + } + """ +) +``` + +The attributed string can be displayed using a UILabel or UITextView. diff --git a/Sources/Runestone/StringSyntaxHighlighter.swift b/Sources/Runestone/StringSyntaxHighlighter.swift new file mode 100644 index 000000000..000122d47 --- /dev/null +++ b/Sources/Runestone/StringSyntaxHighlighter.swift @@ -0,0 +1,105 @@ +import UIKit + +/// Syntax highlights a string. +/// +/// An instance of `StringSyntaxHighlighter` can be used to syntax highlight a string without needing to create a `TextView`. +public final class StringSyntaxHighlighter { + /// The theme to use when syntax highlighting the text. + public var theme: Theme + /// The language to use when parsing the text. + public var language: TreeSitterLanguage + /// Object that can provide embedded languages on demand. A strong reference will be stored to the language provider. + public var languageProvider: TreeSitterLanguageProvider? + /// The number of points by which to adjust kern. + /// + /// The default value is 0 meaning that kerning is disabled. + public var kern: CGFloat = 0 + /// The tab length determines the width of the tab measured in space characers. + /// + /// The default value is 4 meaning that a tab is four spaces wide. + public var tabLength: Int = 4 + /// The line-height is multiplied with the value. + public var lineHeightMultiplier: CGFloat = 1 + + /// Creates an object that can syntax highlight a text. + /// - Parameters: + /// - theme: The theme to use when syntax highlighting the text. + /// - language: The language to use when parsing the text + /// - languageProvider: Object that can provide embedded languages on demand. A strong reference will be stored to the language provider.. + public init( + theme: Theme = DefaultTheme(), + language: TreeSitterLanguage, + languageProvider: TreeSitterLanguageProvider? = nil + ) { + self.theme = theme + self.language = language + self.languageProvider = languageProvider + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Syntax highlights the text using the configured syntax highlighter. + /// - Parameter text: The text to be syntax highlighted. + /// - Returns: An attributed string containing the syntax highlighted text. + public func syntaxHighlight(_ text: String) -> NSAttributedString { + let mutableString = NSMutableString(string: text) + let stringView = StringView(string: mutableString) + let lineManager = LineManager(stringView: stringView) + lineManager.rebuild() + let languageMode = TreeSitterLanguageMode(language: language, languageProvider: languageProvider) + let internalLanguageMode = languageMode.makeInternalLanguageMode( + stringView: stringView, + lineManager: lineManager + ) + internalLanguageMode.parse(mutableString) + let tabWidth = TabWidthMeasurer.tabWidth(tabLength: tabLength, font: theme.font) + let mutableAttributedString = NSMutableAttributedString(string: text) + let defaultAttributes = DefaultStringAttributes( + textColor: theme.textColor, + font: theme.font, + kern: kern, + tabWidth: tabWidth + ) + defaultAttributes.apply(to: mutableAttributedString) + applyLineHeightMultiplier(to: mutableAttributedString) + let byteRange = ByteRange(from: 0, to: text.byteCount) + let syntaxHighlighter = internalLanguageMode.createLineSyntaxHighlighter() + syntaxHighlighter.theme = theme + let syntaxHighlighterInput = LineSyntaxHighlighterInput( + attributedString: mutableAttributedString, + byteRange: byteRange + ) + syntaxHighlighter.syntaxHighlight(syntaxHighlighterInput) + return mutableAttributedString + } +} + +private extension StringSyntaxHighlighter { + private func applyLineHeightMultiplier(to attributedString: NSMutableAttributedString) { + let scaledLineHeight = theme.font.totalLineHeight * lineHeightMultiplier + let mutableParagraphStyle = getMutableParagraphStyle(from: attributedString) + mutableParagraphStyle.lineSpacing = scaledLineHeight - theme.font.totalLineHeight + let range = NSRange(location: 0, length: attributedString.length) + attributedString.beginEditing() + attributedString.removeAttribute(.paragraphStyle, range: range) + attributedString.addAttribute(.paragraphStyle, value: mutableParagraphStyle, range: range) + attributedString.endEditing() + } + + private func getMutableParagraphStyle( + from attributedString: NSMutableAttributedString + ) -> NSMutableParagraphStyle { + guard let attributeValue = attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) else { + return NSMutableParagraphStyle() + } + guard let paragraphStyle = attributeValue as? NSParagraphStyle else { + fatalError("Expected .paragraphStyle attribute to be instance of NSParagraphStyle") + } + guard let mutableParagraphStyle = paragraphStyle.mutableCopy() as? NSMutableParagraphStyle else { + fatalError("Expected mutableCopy() to return an instance of NSMutableParagraphStyle") + } + return mutableParagraphStyle + } +}