├── .gitignore ├── .spi.yml ├── Documentation ├── Messages.md ├── Overview.md └── README.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── Playgrounds └── CodeEditor.playground │ ├── Contents.swift │ ├── contents.xcplayground │ └── timeline.xctimeline ├── README.md ├── Sources ├── CodeEditorView │ ├── CodeActions.swift │ ├── CodeEditing.swift │ ├── CodeEditor.swift │ ├── CodeStorage.swift │ ├── CodeStorageDelegate.swift │ ├── CodeView.swift │ ├── Constants.swift │ ├── GutterView.swift │ ├── LineMap.swift │ ├── MessageViews.swift │ ├── MinimapView.swift │ ├── OSDefinitions.swift │ ├── ScrollViewExtras.swift │ ├── TextContentStorageExtras.swift │ ├── TextLayoutManagerExtras.swift │ ├── TextView.swift │ ├── Theme.swift │ ├── UIHostingView.swift │ └── ViewModifiers.swift └── LanguageSupport │ ├── AgdaConfiguration.swift │ ├── CabalConfiguration.swift │ ├── CypherConfiguration.swift │ ├── HaskellConfiguration.swift │ ├── LanguageConfiguration.swift │ ├── LanguageService.swift │ ├── Location.swift │ ├── Message.swift │ ├── SQLiteConfiguration.swift │ ├── SwiftConfiguration.swift │ └── Tokeniser.swift ├── Tests └── CodeEditorTests │ ├── CodeEditorTests.swift │ ├── CypherConfigurationTests.swift │ ├── LineMapTests.swift │ ├── SQLiteConfigurationTests.swift │ ├── TokenTests.swift │ └── XCTestManifests.swift └── app-demo-images ├── iOS-light-example.png └── macOS-dark-example.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | /.swiftpm 7 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [LanguageSupport, CodeEditorView] 5 | -------------------------------------------------------------------------------- /Documentation/Messages.md: -------------------------------------------------------------------------------- 1 | # Messages 2 | 3 | The data model underlying messages is defined in [Message.swift](/Sources/CodeEditorView/Message.swift), whereas message rendering is defined in [MessageViews.swift](/Sources/CodeEditorView/MessageViews.swift). The message views are entirely implemented in SwiftUI and via `NSHostingView` and [UIHostingView.swift](/Sources/CodeEditorView/UIHostingView.swift), respectively, added as subviews to the [CodeView.swift](/Sources/CodeEditorView/CodeView.swift). 4 | 5 | ## Message views 6 | 7 | Messages are always displayed in groups, namely as part of the group of messages that occur on a single line. The view of a message group has two flavours: (1) an inine view state and (2) a popup view state. Both of these views are combined inside the `StatefulMessageView`, which supports toggling between both flavours by clicking on the view. 8 | 9 | ### Inline view 10 | 11 | The inline view is positioned at the right edge of the text container, precisely matching the height of the first line fragement rectangle of the line to which the message group pertains. 12 | 13 | Due to the rather limited space, the inline view only provides a message group summary. This includes a tally of the number of messages in each category together with as much of the summary of the groups principal message as there is room to display. The principal message is always one of the messages of the most urgent category occuring in the message group. 14 | 15 | The width of the inline view is constrained by the width of the line on which it gets displayed. It will use the empty space to the right of the text to display as much of the message summary as it can. The inline view has got a minimal width (defined in `MessageView.minimumInlineWidth`), which it enforces by truncating the first line fragment rectangle of the line. If the line of code encroaches on the space needed for the inline view to display at its minimal width, the truncated line fragment rectangle will trigger a line break. 16 | 17 | ### Popup view 18 | 19 | In contrast to the inline view, the popup view occupies as much space as it needs. It floats above the text, just underneath the last line fragment rectangle of the line that it belongs to. It is always offset a fixed amount from the right hand edge (`MessageView.popupRightSideOffset`) and also doesn't extend entirely to the text containers left hand side. 20 | 21 | -------------------------------------------------------------------------------- /Documentation/Overview.md: -------------------------------------------------------------------------------- 1 | # Documentation of the SwiftUI view `CodeEditor` 2 | 3 | 4 | `CodeEditor` is a SwiftUI view implementing a general-purpose code editor. It works on macOS (from 12.0) and on iOS (from 15.0). As far as prossible, it tries to provide the same functionality on macOS and iOS, but this is not always possible because the iOS version of TextKit does miss some of the APIs exposed on macOS, such as the type setter API. The currently most significant omission on iOS is the minimap. 5 | 6 | 7 | ## The main view 8 | 9 | Typical usage of the `CodeEditor` view is as roughly follows. 10 | 11 | ```swift 12 | struct ContentView: View { 13 | @State private var text: String = "My awesome code..." 14 | @State private var messages: Set> = Set () 15 | 16 | @Environment(\.colorScheme) private var colorScheme: ColorScheme 17 | 18 | @SceneStorage("editPosition") private var editPosition: CodeEditor.Position = CodeEditor.Position() 19 | 20 | var body: some View { 21 | CodeEditor(text: $text, position: $editPosition, messages: $messages, language: .swift) 22 | .environment(\.codeEditorTheme, 23 | colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight) 24 | } 25 | } 26 | ``` 27 | 28 | The view receives here four arguments: 29 | 30 | 1. a binding to a `String` that contains the edited text, 31 | 2. a binding to the current edit position (i.e., selection and scroll position), 32 | 3. a binding to a set of the currently reported `Messages` pertaining to individual lines of the edited text, and 33 | 4. a language configuration that controls language-specific editing suppport such as syntax highlighting. 34 | 35 | The binding to the edit position and the language configuration are optional. Moreover, there is a fifth optional argument that we are not using here, namely, the layout used for the code editor view. 36 | 37 | Moreover, a `CodeEditor` honours the `codeEditorTheme` environment variable, which determines the theme to use for syntax highlighting. 38 | 39 | To see a complete working example, see the [CodeEditorView demo app](https://github.com/mchakravarty/CodeEditorDemo). 40 | 41 | 42 | ## Messages 43 | 44 | Messages are notifications that can be reported on a line by line basis. They can be created using the following initialiser: 45 | 46 | ```swift 47 | init(category: Message.Category, length: Int, summary: String, description: NSAttributedString?) 48 | ``` 49 | 50 | The message category determines the type of message, the length are the number of characters that ought to be marked (but this is not implemented yet). The summary is a short form of the message used inline on the right hand side of the code view, whereas the description an optional more detailed version specifies. For example, a summary could be that there is a type error and the description could explain the nature of the type error in more detail. 51 | 52 | Initially, the summary is displayed inline. Once the user clicks or taps on the summary, the detailed description is shown in a popup. Clicking or tapping on the detailed description collapses it again. 53 | 54 | New messages are reported by adding them to the set. Similarily, they can be removed the message set to retract them. The code editor will also automatically remove any messages on lines that have been edited. 55 | 56 | More details about messages support are in [Messages](Messages.md). 57 | 58 | ### Locations 59 | 60 | Messages are *located* by way of a generic wrapper: 61 | 62 | ```swift 63 | struct TextLocated { 64 | let location: TextLocation 65 | let entity: Entity 66 | } 67 | 68 | struct TextLocation { 69 | let zeroBasedLine: Int // starts from line 0 70 | let zeroBasedColumn: Int // starts from column 0 71 | } 72 | ``` 73 | 74 | `TextLocation.line` determines the line at which a message is going to be displayed. During editing, messages stick to the lines at which they are reported. For example, if the user adds additional lines before the line at which a message got reported, the message will stick to its original line moving down with it. Note however, that the `Located` wrapper does not get updated in that process, it always specifices the initial line number at the time of reporting. Messages conform to `Identifable` to enable distinguishing between them independently of the reporting location. 75 | 76 | ### Categories 77 | 78 | Currently, four categories are supported (in order of priority): `.live`, `.error`, `.warning`, and `.informational`. The message category is used to selected a message colour out of a message theme. That colour is used as a background when rendering the message and also to highlight the line at which a message gets reported. If multiple messages are reported on the same line, the colour of the inline version (and line highlight) is determined by the message of the highest priority. 79 | 80 | Currently, the message theme is hardcoded, but it will become configurable in the future. (Details are still to be determined.) 81 | 82 | 83 | ## Language configurations 84 | 85 | `LanguageConfiguration`s determine syntaxtic properties, which are used for syntax highlight, bracket matching, and similar syntax-dependent functionality. More precisely, language confugurations provide information that enables the code editor to tokenise the edited code in real time using a custom tokeniser based on `NSRegularExpression`. Tokenisers are finite-state machines (FSM) whose state transitions dependent on the matched regular expressions and who use different regular expressions depending on the FSM state. This enables us to tokenise differently depending on whether we are, for example, in a nested comment or in plain code. The tokeniser is a generic extension of `NSMutableAttributedString` (contained in the file `MutableAttributedString.swift`) and may be of independent interest. 86 | 87 | A language configuration specifies language-dependent tokenisation rules in the form of a struct that determines what comment delimiters to use, regular expressions for string and numeric literals as well as for identifiers. The configuration options are currently still fairly limited. Example configurations for Swift and Haskell are included. Configurations for other languages can be defined in a similar manner. 88 | 89 | 90 | ## Syntax highlighting 91 | 92 | Syntax highlighting is currently completely static and only based on token classification. Longer term, the aim is to have basic highlighting on the basis of token classification (as now) in combination with semantic highlighting on the basis of code analysis as performed, for example, by SourceKit. 93 | 94 | The tokeniser depending on the language configuration uses two new `NSAttributedString.Key`s to mark comments with `.comment` and general tokens with `.token`. The token attribute value is of type `LanguageConfiguration.Token` and gets continously updated as the text edited. For optimal performance, we use on-the-fly custom attribute translation (in a custom subclass of the `NSTextStorage` class cluster, called `CodeStorage`). Moreover, syntax highlighting only varies the foreground colour of tokens to keep type setting independent of highlighting. 95 | 96 | NB: Temporary attributes are no option, because they are no supported by `NSLayoutManager` on iOS. 97 | 98 | ### Themes 99 | 100 | The `Theme` struct determines a font name and a font size together with colours for the various recognised types of tokens and for general colour elements, such as the cursor colour, selection colour, and so on. On iOS, TextKit doesn't allow us to customise the cursor and selection colour idenpendently. Hence, we derive an appropriate tint colour from the theme's selection colour. 101 | -------------------------------------------------------------------------------- /Documentation/README.md: -------------------------------------------------------------------------------- 1 | # Documentation of CodeEditorView 2 | 3 | This directory contains the conceptual [documentation](Overview.md) for the SwiftUI `CodeEditor` view. 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "rearrange", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/ChimeHQ/Rearrange", 7 | "state" : { 8 | "revision" : "0fb658e721c68495f6340c211cc6d4719e6b52d8", 9 | "version" : "1.6.0" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CodeEditorView", 8 | platforms: [ 9 | .macOS(.v14), 10 | .iOS(.v17), 11 | .visionOS(.v1) 12 | ], 13 | products: [ 14 | .library( 15 | name: "LanguageSupport", 16 | targets: ["LanguageSupport"]), 17 | .library( 18 | name: "CodeEditorView", 19 | targets: ["CodeEditorView"]), 20 | ], 21 | dependencies: [ 22 | .package( 23 | url: "https://github.com/ChimeHQ/Rearrange.git", 24 | .upToNextMajor(from: "1.6.0")), 25 | ], 26 | targets: [ 27 | .target( 28 | name: "LanguageSupport", 29 | dependencies: [ 30 | "Rearrange", 31 | ], 32 | swiftSettings: [ 33 | .enableUpcomingFeature("BareSlashRegexLiterals") 34 | ]), 35 | .target( 36 | name: "CodeEditorView", 37 | dependencies: [ 38 | "LanguageSupport", 39 | "Rearrange", 40 | ]), 41 | .testTarget( 42 | name: "CodeEditorTests", 43 | dependencies: ["CodeEditorView"]), 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /Playgrounds/CodeEditor.playground/Contents.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CodeEditorView 3 | 4 | 5 | struct Editor: View { 6 | @State private var text = "main = print 42" 7 | 8 | var body: some View { 9 | CodeEditor(text: $text) 10 | } 11 | } 12 | 13 | let codeEditor = Editor() 14 | 15 | import PlaygroundSupport 16 | PlaygroundPage.current.setLiveView(codeEditor) 17 | -------------------------------------------------------------------------------- /Playgrounds/CodeEditor.playground/contents.xcplayground: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /Playgrounds/CodeEditor.playground/timeline.xctimeline: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI code editor view for iOS, visionOS, and macOS 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmchakravarty%2FCodeEditorView%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/mchakravarty/CodeEditorView) 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmchakravarty%2FCodeEditorView%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/mchakravarty/CodeEditorView) 5 | 6 | The package `CodeEditorView` provides a SwiftUI view implementing a code editor for iOS, visionOS, and macOS whose visual style is inspired by Xcode and that is based on TextKit 2. The currently supported functionality includes syntax highlighting with configurable themes, inline message reporting (warnings, errors, etc), bracket matching, matching bracket insertion, current line highlighting, common code editing operations, and a minimap. 7 | 8 | On macOS, `CodeEditorView` also supports (1) displaying information about identifiers (such as type information and documentation provided in Markdown) as well as (2) code completion. This support is independent of how the underlying information is computed — a common choice is to use a language server based on the Language Server Protocol (LSP). This functionality will eventually also be supported on iOS. 9 | 10 | ## Screenshots of the demo app 11 | 12 | This is the default dark theme on macOS. Like in Xcode, messages have got an inline view on the right-hand side of the screen, which pops up into a larger overlay to display more information. The minimap on the right provides an outline of the edited text. 13 | 14 | 15 | 16 | The following is the default light theme on iOS. 17 | 18 | 19 | 20 | 21 | ## How to use it 22 | 23 | Typical usage of the view is as follows. 24 | 25 | ```swift 26 | import SwiftUI 27 | import CodeEditorView 28 | import LanguageSupport 29 | 30 | struct ContentView: View { 31 | @State private var text: String = "My awesome code..." 32 | @State private var position: CodeEditor.Position = CodeEditor.Position() 33 | @State private var messages: Set> = Set() 34 | 35 | @Environment(\.colorScheme) private var colorScheme: ColorScheme 36 | 37 | var body: some View { 38 | CodeEditor(text: $text, position: $position, messages: $messages, language: .swift()) 39 | .environment(\.codeEditorTheme, 40 | colorScheme == .dark ? Theme.defaultDark : Theme.defaultLight) 41 | } 42 | } 43 | ``` 44 | 45 | 46 | ## Demo app 47 | 48 | To see the `CodeEditorView` in action, have a look at the repo with a [cross-platform demo app](https://github.com/mchakravarty/CodeEditorDemo). 49 | 50 | 51 | ## Documentation 52 | 53 | For more information, see the [package documentation](Documentation/Overview.md). 54 | 55 | 56 | ## Status 57 | 58 | I consider this package still to be of pre-release quality, but at this stage, it is mostly a set of known bugs, which prevents it from being a 1.0. 59 | 60 | ## License 61 | 62 | Copyright [2021..2025] Manuel M. T. Chakravarty. 63 | 64 | Distributed under the Apache-2.0 license — see the [license file](LICENSE) for details. 65 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // CodeEditorView 4 | // 5 | // Created by Manuel M T Chakravarty on 12/01/2025. 6 | // 7 | 8 | 9 | // MARK: - 10 | // MARK: Key codes 11 | 12 | let keyCodeReturn: UInt16 = 0x24 13 | let keyCodeTab: UInt16 = 0x30 14 | let keyCodeESC: UInt16 = 0x35 15 | let keyCodeDownArrow: UInt16 = 0x7D 16 | let keyCodeUpArrow: UInt16 = 0x7E 17 | 18 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/GutterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GutterView.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 23/09/2020. 6 | // 7 | 8 | import os 9 | 10 | import Rearrange 11 | 12 | import LanguageSupport 13 | 14 | 15 | private let logger = Logger(subsystem: "org.justtesting.CodeEditorView", category: "GutterView") 16 | 17 | 18 | #if os(iOS) || os(visionOS) 19 | 20 | // MARK: - 21 | // MARK: UIKit version 22 | 23 | import UIKit 24 | 25 | 26 | private let fontDescriptorFeatureIdentifier = OSFontDescriptor.FeatureKey.type 27 | private let fontDescriptorTypeIdentifier = OSFontDescriptor.FeatureKey.selector 28 | 29 | 30 | #elseif os(macOS) 31 | 32 | 33 | // MARK: - 34 | // MARK: AppKit version 35 | 36 | import AppKit 37 | 38 | 39 | private let fontDescriptorFeatureIdentifier = OSFontDescriptor.FeatureKey.typeIdentifier 40 | private let fontDescriptorTypeIdentifier = OSFontDescriptor.FeatureKey.selectorIdentifier 41 | 42 | #endif 43 | 44 | 45 | // MARK: - 46 | // MARK: Shared code 47 | 48 | private let lineNumberColour = OSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 0.5) 49 | 50 | final class GutterView: OSView { 51 | 52 | /// The text view that this gutter belongs to. 53 | /// 54 | weak var textView: OSTextView? 55 | 56 | /// The code storage containing the text accompanied by this gutter. 57 | /// 58 | var codeStorage: CodeStorage 59 | 60 | /// The current code editor theme 61 | /// 62 | var theme: Theme 63 | 64 | /// Accessor for the associated text view's message views. 65 | /// 66 | let getMessageViews: () -> MessageViews 67 | 68 | /// Determines whether this gutter is for a main code view or for the minimap of a code view. 69 | /// 70 | let isMinimapGutter: Bool 71 | 72 | /// Create and configure a gutter view for the given text view. The gutter view is transparent, so that we can place 73 | /// highlight views behind it. 74 | /// 75 | init(frame: CGRect, 76 | textView: OSTextView, 77 | codeStorage: CodeStorage, 78 | theme: Theme, 79 | getMessageViews: @escaping () -> MessageViews, 80 | isMinimapGutter: Bool) 81 | { 82 | self.textView = textView 83 | self.codeStorage = codeStorage 84 | self.theme = theme 85 | self.getMessageViews = getMessageViews 86 | self.isMinimapGutter = isMinimapGutter 87 | super.init(frame: frame) 88 | #if os(iOS) || os(visionOS) 89 | isOpaque = false 90 | #endif 91 | } 92 | 93 | @available(*, unavailable) 94 | required init(coder: NSCoder) { 95 | fatalError("CodeEditorView.GutterView.init(coder:) not implemented") 96 | } 97 | 98 | #if os(macOS) 99 | // Use the coordinate system of the associated text view. 100 | override var isFlipped: Bool { textView?.isFlipped ?? false } 101 | #endif 102 | } 103 | 104 | extension GutterView { 105 | 106 | var optTextLayoutManager: NSTextLayoutManager? { textView?.optTextLayoutManager } 107 | var optTextContentStorage: NSTextContentStorage? { textView?.optTextContentStorage } 108 | var optTextContainer: NSTextContainer? { textView?.optTextContainer } 109 | var optLineMap: LineMap? { (codeStorage.delegate as? CodeStorageDelegate)?.lineMap } 110 | 111 | // MARK: - 112 | // MARK: Gutter notifications 113 | 114 | /// Notifies the gutter view that a range of characters will be redrawn by the layout manager or that there are 115 | /// selection status changes; thus, the corresponding gutter area might require redrawing, too. 116 | /// 117 | /// - Parameters: 118 | /// - charRange: The invalidated range of characters. It will be trimmed to be within the valid character range of 119 | /// the underlying text storage. If this argument is `nil`, the entire gutter view is invalidated. 120 | /// 121 | /// We invalidate the area corresponding to entire paragraphs. This makes a difference in the presence of line 122 | /// breaks. 123 | /// 124 | func invalidateGutter(for charRange: NSRange? = nil) { 125 | guard let charRange else { 126 | #if os(macOS) 127 | needsDisplay = true 128 | #else 129 | setNeedsDisplay() 130 | #endif 131 | return 132 | } 133 | 134 | guard let textLayoutManager = optTextLayoutManager, 135 | let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage, 136 | let viewPortRange = textLayoutManager.textViewportLayoutController.viewportRange, 137 | let viewPortRangeExt = textContentStorage.range(for: viewPortRange).shifted(endBy: 1), 138 | // with the above shift we allow for an insertion point at the end of the text 139 | let charRangeInViewPort = viewPortRangeExt.intersection(charRange), 140 | let string = textContentStorage.textStorage?.string as NSString? 141 | else { return } 142 | 143 | 144 | // We call `paragraphRange(for:_)` safely by boxing `charRange` to the allowed range. 145 | let extendedCharRange = string.paragraphRange(for: charRangeInViewPort.clamped(to: string.length)) 146 | 147 | if let textRange = textContentStorage.textRange(for: extendedCharRange), 148 | let (y: y, height: height) = textLayoutManager.textLayoutFragmentExtent(for: textRange), 149 | height > 0 150 | { 151 | setNeedsDisplay(gutterRectFrom(y: y, height: height)) 152 | } 153 | 154 | if charRange.max == string.length, 155 | let endLocation = textContentStorage.textLocation(for: charRange.max), 156 | let textLayoutFragment = textLayoutManager.textLayoutFragment(for: endLocation), 157 | let textRect = textLayoutFragment.layoutFragmentFrameExtraLineFragment 158 | { 159 | setNeedsDisplay(gutterRectForLineNumbersFrom(textRect: textRect)) 160 | } 161 | } 162 | 163 | // MARK: - 164 | // MARK: Gutter drawing 165 | 166 | override func draw(_ rect: CGRect) { 167 | guard let textLayoutManager = optTextLayoutManager, 168 | let textContentStorage = optTextContentStorage, 169 | let lineMap = optLineMap 170 | else { return } 171 | 172 | // We can't draw the gutter without having layout information for the viewport. 173 | let viewPortBounds = textLayoutManager.textViewportLayoutController.viewportBounds 174 | textLayoutManager.ensureLayout(for: viewPortBounds) 175 | 176 | let desc = OSFont.systemFont(ofSize: theme.fontSize).fontDescriptor.addingAttributes( 177 | [ OSFontDescriptor.AttributeName.featureSettings: 178 | [ 179 | [ 180 | fontDescriptorFeatureIdentifier: kNumberSpacingType, 181 | fontDescriptorTypeIdentifier: kMonospacedNumbersSelector, 182 | ], 183 | [ 184 | fontDescriptorFeatureIdentifier: kStylisticAlternativesType, 185 | fontDescriptorTypeIdentifier: kStylisticAltOneOnSelector, // alt 6 and 9 186 | ], 187 | [ 188 | fontDescriptorFeatureIdentifier: kStylisticAlternativesType, 189 | fontDescriptorTypeIdentifier: kStylisticAltTwoOnSelector, // alt 4 190 | ] 191 | ] 192 | ] 193 | ) 194 | #if os(iOS) || os(visionOS) 195 | let font = OSFont(descriptor: desc, size: 0) 196 | #elseif os(macOS) 197 | let font = OSFont(descriptor: desc, size: 0) ?? OSFont.systemFont(ofSize: 0) 198 | #endif 199 | 200 | let selectedLines = textView?.selectedLines ?? Set(1..<2) 201 | 202 | // We always enumerate all lines that are in the viewport (but we don't draw if they fall outside of `rect`). 203 | let textRange = textLayoutManager.textViewportLayoutController.viewportRange 204 | ?? textLayoutManager.documentRange, 205 | characterRange = textContentStorage.range(for: textRange) 206 | 207 | // Draw line numbers unless this is a gutter for a minimap 208 | if !isMinimapGutter { 209 | 210 | let lineRange = lineMap.linesOf(range: characterRange) 211 | 212 | // Text attributes for the line numbers 213 | let lineNumberStyle = NSMutableParagraphStyle() 214 | lineNumberStyle.alignment = .right 215 | lineNumberStyle.tailIndent = -theme.fontSize / 11 216 | let textAttributesDefault = [NSAttributedString.Key.font: font, 217 | .foregroundColor: lineNumberColour, 218 | .paragraphStyle: lineNumberStyle, 219 | .kern: NSNumber(value: Float(-theme.fontSize / 11))], 220 | textAttributesSelected = [NSAttributedString.Key.font: font, 221 | .foregroundColor: theme.textColour, 222 | .paragraphStyle: lineNumberStyle, 223 | .kern: NSNumber(value: Float(-theme.fontSize / 11))] 224 | 225 | for line in lineRange { // NB: These are zero-based line numbers 226 | 227 | guard let lineStartLocation = textContentStorage.textLocation(for: lineMap.lines[line].range.location), 228 | let textLayoutFragment = textLayoutManager.textLayoutFragment(for: lineStartLocation) 229 | else { continue } 230 | 231 | let gutterRect = gutterRectForLineNumbersFrom(textRect: 232 | textLayoutFragment.layoutFragmentFrameWithoutExtraLineFragment), 233 | attributes = selectedLines.contains(line) ? textAttributesSelected : textAttributesDefault 234 | if gutterRect.intersects(rect) { 235 | ("\(line + 1)" as NSString).draw(in: gutterRect, withAttributes: attributes) 236 | } 237 | } 238 | 239 | // If we are at the end, we also draw a line number for the extra line fragement if that exists 240 | if lineRange.endIndex == lineMap.lines.count, 241 | let endLocation = textContentStorage.location(textRange.endLocation, offsetBy: -1), 242 | let textLayoutFragment = textLayoutManager.textLayoutFragment(for: endLocation), 243 | let textRect = textLayoutFragment.layoutFragmentFrameExtraLineFragment 244 | { 245 | 246 | let gutterRect = gutterRectForLineNumbersFrom(textRect: textRect), 247 | attributes = selectedLines.contains(lineRange.endIndex - 1) ? textAttributesSelected : textAttributesDefault 248 | if gutterRect.intersects(rect) { 249 | ("\(lineMap.lines.count)" as NSString).draw(in: gutterRect, withAttributes: attributes) 250 | } 251 | 252 | } else if textRange.isEmpty { // Empty document (i.e., there is no layout fragment) 253 | 254 | let firstTextRect = CGRect(x: 0, y: 0, width: 0, height: font.lineHeight), 255 | gutterRect = gutterRectForLineNumbersFrom(textRect: firstTextRect) 256 | if gutterRect.intersects(rect) { 257 | ("\(1)" as NSString).draw(in: gutterRect, withAttributes: textAttributesSelected) 258 | } 259 | } 260 | } 261 | 262 | } 263 | } 264 | 265 | extension GutterView { 266 | 267 | /// Compute the full width rectangle in the gutter from its vertical extent. 268 | /// 269 | private func gutterRectFrom(y: CGFloat, height: CGFloat) -> CGRect { 270 | return CGRect(origin: CGPoint(x: 0, y: y + (textView?.textContainerOrigin.y ?? 0)), 271 | size: CGSize(width: frame.size.width, height: height)) 272 | } 273 | 274 | /// Compute the line number glyph rectangle in the gutter from a text container rectangle, such that they both have 275 | /// the same vertical extension. 276 | /// 277 | private func gutterRectForLineNumbersFrom(textRect: CGRect) -> CGRect { 278 | let gutterRect = gutterRectFrom(y: textRect.minY, height: textRect.height) 279 | return CGRect(x: gutterRect.origin.x + gutterRect.size.width * 1/7, 280 | y: gutterRect.origin.y, 281 | width: gutterRect.size.width * 5/7, 282 | height: gutterRect.size.height) 283 | } 284 | 285 | /// Compute the full width rectangle in the text container from a gutter rectangle, such that they both have the same 286 | /// vertical extension. 287 | /// 288 | private func textRectFrom(gutterRect: CGRect) -> CGRect { 289 | let containerWidth = optTextContainer?.size.width ?? 0 290 | return CGRect(origin: CGPoint(x: frame.size.width, y: gutterRect.origin.y - (textView?.textContainerOrigin.y ?? 0)), 291 | size: CGSize(width: containerWidth - frame.size.width, height: gutterRect.size.height)) 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/LineMap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LineMap.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 29/09/2020. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | /// Keeps track of the character ranges and parametric `LineInfo` for all lines in a string. 12 | /// 13 | struct LineMap { 14 | 15 | /// The character range of the line in the underlying string together with additional information if available. 16 | /// 17 | typealias OneLine = (range: NSRange, info: LineInfo?) 18 | 19 | /// One entry per line of the underlying string. 20 | /// 21 | var lines: [OneLine] = [] 22 | 23 | /// MARK: - 24 | /// MARK: Initialisation 25 | 26 | /// Direct initialisation for testing. 27 | /// 28 | init(lines: [OneLine]) { self.lines = lines } 29 | 30 | /// Initialise a line map with the string to be mapped. 31 | /// 32 | init(string: String) { lines.append(contentsOf: linesOf(string: string)) } 33 | 34 | 35 | // MARK: - 36 | // MARK: Queries 37 | 38 | /// Safe lookup of the information pertaining to a given line. 39 | /// 40 | /// - Parameter line: The zero-based line number to look up. 41 | /// - Returns: The description of the given line if it is within the valid range of the line map. 42 | /// 43 | func lookup(line: Int) -> OneLine? { return (line >= 0 && line < lines.count) ? lines[line] : nil } 44 | 45 | /// Return the character range covered by the given range of lines. Safely handles out of bounds situations. 46 | /// 47 | /// NB: Line numbers are zero-based. 48 | /// 49 | func charRangeOf(lines: Range) -> NSRange { 50 | let startRange = lookup(line: lines.first ?? 0)?.range ?? .zero, 51 | endRange = lookup(line: lines.last ?? 0)?.range ?? .zero 52 | return NSRange(location: startRange.location, length: endRange.max - startRange.location) 53 | } 54 | 55 | /// Determine the zero-based line number of the line containing the characters at the given string index. (Safe to be 56 | /// called with an out of bounds index.) 57 | /// 58 | /// - Parameter index: The string index of the characters whose line we want to determine. 59 | /// - Returns: The zero-based line number containing the indexed character if the index is within the bounds of the 60 | /// string. 61 | /// 62 | /// - Complexity: This functions asymptotic complexity is logarithmic in the number of lines contained in the line map. 63 | /// 64 | func lineContaining(index: Int) -> Int? { 65 | var lineRange = 0.. 1 { 68 | 69 | let middle = lineRange.startIndex + lineRange.count / 2 70 | if index < lines[middle].range.location { 71 | 72 | lineRange = lineRange.startIndex.. Int? { 105 | if let lastLine = lines.last, lastLine.range.max == index { return lines.count - 1 } 106 | else { return lineContaining(index: index) } 107 | } 108 | 109 | /// Determine the zero-based line that contains the cursor position specified by the given string index together with 110 | /// the line position. (Safe to be called with an out of bounds index.) 111 | /// 112 | /// - Parameter index: The string index of the cursor position whose line we want to determine. 113 | /// - Returns: The zero-based line containing the given cursor poisition together with line position if the index is 114 | /// within the bounds of the string or just beyond. 115 | /// 116 | /// - Complexity: This functions asymptotic complexity is logarithmic in the number of lines contained in the line 117 | /// map. 118 | /// 119 | func lineAndPositionOf(index: Int) -> (line: Int, position: Int)? { 120 | guard let line = lineOf(index: index), 121 | let range = lookup(line: line)?.range 122 | else { return nil } 123 | 124 | return (line: line, position: index - range.location) 125 | } 126 | 127 | /// Given a character range, return the smallest zero-based line range that includes the characters. Deal with out of 128 | /// bounds conditions by clipping to the front and end of the line range, respectively. 129 | /// 130 | /// - Parameter range: The character range for which we want to know the line range. 131 | /// - Returns: The smallest range of lines that includes all characters in the given character range. The start value 132 | /// of that range is greater or equal 0. 133 | /// 134 | /// There are two special cases: 135 | /// - If (1) the range is empty, (2) its location (= insertion) at the end of the string, and (3) the text ends on a 136 | /// trailing empty line, the result is the trailing line on its own. 137 | /// - If the character range is of length zero, we return the line of the start location. We do that also if the start 138 | /// location is just behind the last character of the text. 139 | /// 140 | func linesContaining(range: NSRange) -> Range { 141 | let 142 | start = range.location < 0 ? 0 : range.location, 143 | end = range.length <= 0 ? start : range.max - 1, 144 | startLine = lineOf(index: start), 145 | endLine = lineContaining(index: end), 146 | lastLine = lines.count - 1, 147 | lastLineRange = lines[lastLine].range 148 | 149 | if let startLine = startLine { 150 | 151 | if range.length < 0 { return startLine..(lastLine...lastLine) } 153 | else { return Range(startLine...(endLine ?? lastLine)) } 154 | 155 | } else { 156 | 157 | if range.location < 0 { return 0..<0 } else { return lastLine.. Range { 178 | let lastLine = lines.count - 1, 179 | lastLineRange = lines[lastLine].range 180 | 181 | if range.max == lastLineRange.location && lastLineRange.length == 0 { 182 | 183 | // Range reaches to the end of text => extend 'endLine' to 'lastLine' 184 | return Range(linesContaining(range: range).startIndex...lastLine) 185 | 186 | } else { 187 | 188 | return linesContaining(range: range) 189 | 190 | } 191 | } 192 | 193 | /// Compute the lines affected by an editing activity. 194 | /// 195 | /// - Parameters: 196 | /// - editedRange: The character range that was affected by editing (after the edit). 197 | /// - delta: The length increase of the edited string (negative if it got shorter). 198 | /// - Returns: The zero-based range of lines (of the original string) that is affected by the editing action. 199 | /// 200 | func linesAffected(by editedRange: NSRange, changeInLength delta: Int) -> Range { 201 | 202 | if let shiftedRange = editedRange.shifted(endBy: -delta) { 203 | 204 | // To compute the line range, we extend the character range by one extra character. This is crucial as, if the 205 | // edited range ends on a newline, this may insert a new line break, which means, line *after* the new line break 206 | // also belongs to the affected lines. 207 | let oldStringRange = NSRange(location: 0, length: (lines.last?.range ?? .zero).max) 208 | return linesOf(range: extend(range: shiftedRange, clippingTo: oldStringRange)) 209 | 210 | } else { return 0..<0 } 211 | } 212 | 213 | // MARK: - 214 | // MARK: Editing 215 | 216 | /// Set the info field for the given line (starting from 0). 217 | /// 218 | /// - Parameters: 219 | /// - line: The zero-based line whose info field ought to be set. 220 | /// - info: The new info value for that line. 221 | /// 222 | /// NB: Ignores lines that do not exist. 223 | /// 224 | mutating func setInfoOf(line: Int, to info: LineInfo?) { 225 | guard line < lines.count else { return } 226 | 227 | lines[line] = (range: lines[line].range, info: info) 228 | } 229 | 230 | /// Update the line map given the specified editing activity of the underlying string. It resets the info field for 231 | /// each affected line. 232 | /// 233 | /// - Parameters: 234 | /// - string: The string after editing. 235 | /// - editedRange: The character range that was affected by editing (after the edit). 236 | /// - delta: The length increase of the edited string (negative if it got shorter). 237 | /// 238 | /// NB: The line after the `editedRange` will be updated (and info fields be invalidated) if the `editedRange` ends on 239 | /// a newline. 240 | /// 241 | mutating func updateAfterEditing(string: String, range editedRange: NSRange, changeInLength delta: Int) { 242 | 243 | // To compute line ranges, we extend all character ranges by one extra character. This is crucial as, if the 244 | // edited range ends on a newline, this may insert a new line break, which means, we also need to update the line 245 | // *after* the new line break. 246 | // 247 | let nsString = string as NSString, 248 | newStringRange = NSRange(location: 0, length: nsString.length), 249 | oldLinesRange = linesAffected(by: editedRange, changeInLength: delta), // NB: `linesAffected` extends itself 250 | extendedEditedRange = extend(range: editedRange, clippingTo: newStringRange), 251 | newLinesRange = nsString.lineRange(for: extendedEditedRange), 252 | newLinesString = nsString.substring(with: newLinesRange), 253 | newLines = linesOf(string: newLinesString).map{ shift(line: $0, by: newLinesRange.location) } 254 | 255 | // If the newly inserted text ends on a new line, we need to remove the empty trailing line in the new lines array 256 | // unless the range of those new lines extends until the end of the string. 257 | let dropEmptyNewLine = newLines.last?.range.length == 0 && oldLinesRange.last != lines.count - 1, 258 | adjustedNewLines = dropEmptyNewLine ? newLines.dropLast() : newLines 259 | 260 | lines.replaceSubrange(oldLinesRange, with: adjustedNewLines) 261 | 262 | // All ranges after the edited range of lines need to be adjusted. 263 | // 264 | for i in oldLinesRange.startIndex.advanced(by: adjustedNewLines.count) ..< lines.count { 265 | lines[i] = shift(line: lines[i], by: delta) 266 | } 267 | } 268 | 269 | // MARK: - 270 | // MARK: Helpers 271 | 272 | /// Shift the range of `line` by `delta`. 273 | /// 274 | private func shift(line: OneLine, by delta: Int) -> OneLine { 275 | return (range: NSRange(location: line.range.location + delta, length: line.range.length), info: line.info) 276 | } 277 | 278 | /// Extract the corresponding array of line ranges out of the given string. 279 | /// 280 | private func linesOf(string: String) -> [OneLine] { 281 | let nsString = string as NSString 282 | 283 | var resultingLines: [OneLine] = [] 284 | 285 | // Enumerate all lines in `nsString`, adding them to the `resultingLines`. 286 | // 287 | var currentIndex = 0 288 | while currentIndex < nsString.length { 289 | 290 | let currentRange = nsString.lineRange(for: NSRange(location: currentIndex, length: 0)) 291 | resultingLines.append((range: currentRange, info: nil)) 292 | currentIndex = currentRange.max 293 | 294 | } 295 | 296 | // Check if there is an empty last line (due to a linebreak being at the end of the text), and if so, add that 297 | // extra empty line to the `resultingLines` as well. 298 | // 299 | let lastRange = nsString.lineRange(for: NSRange(location: nsString.length, length: 0)) 300 | if lastRange.length == 0 { 301 | resultingLines.append((range: lastRange, info: nil)) 302 | } 303 | 304 | return resultingLines 305 | } 306 | 307 | /// Extend the `range` by one character, clipped by the `stringRange`, but such that a zero length range after the 308 | /// end of the string is preserved. 309 | /// 310 | private func extend(range: NSRange, clippingTo stringRange: NSRange) -> NSRange { 311 | return 312 | range.location == stringRange.max 313 | ? NSRange(location: range.location, length: 0) 314 | : NSIntersectionRange(NSRange(location: range.location, length: range.length + 1), stringRange) 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/MinimapView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MinimapView.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 05/05/2021. 6 | // 7 | // TextKit 2 subclasses to implement minimap functionality. 8 | // 9 | // The idea here is that, in place of drawing the actual glyphs, we draw small rectangles in the glyph's foreground 10 | // colour. Instead of actual glyphs, we draw fixed-sized rectangles. The size of the minimap rectangles corresponds 11 | // to that of the main code view font, but at a fraction of the fontsize determined by `minimapRatio`. 12 | // 13 | // The implementation uses custom `NSTextLayoutFragment` and `NSTextLineFragment` subclasses, which, based on the 14 | // full-sized layout, calculate the scaled down layout for the minimap. This implies that `NSTextLayoutFragment` 15 | // generates the minimap versions of `NSTextLineFragment` whenever its array of line fragments changes. Moreover, 16 | // we replace the standard glyph drawing by drawing of rectangles, in the minimap-subclass of `NSTextLineFragment`. 17 | 18 | import SwiftUI 19 | 20 | 21 | /// The factor determining how much smaller the minimap is than the actual code view. 22 | /// 23 | let minimapRatio = CGFloat(8) 24 | 25 | 26 | #if os(iOS) || os(visionOS) 27 | 28 | // MARK: - 29 | // MARK: Minimap view for iOS 30 | 31 | /// Customised text view for the minimap. 32 | /// 33 | class MinimapView: UITextView { 34 | weak var codeView: CodeView? 35 | 36 | // Highlight the current line. 37 | // 38 | override func draw(_ rect: CGRect) { 39 | super.draw(rect) 40 | 41 | let rectWithinBounds = rect.intersection(bounds) 42 | 43 | guard let textLayoutManager = textLayoutManager, 44 | let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage 45 | else { return } 46 | 47 | let viewportRange = textLayoutManager.textViewportLayoutController.viewportRange 48 | 49 | // If the selection is an insertion point, highlight the corresponding line 50 | if let location = codeView?.insertionPoint, 51 | let textLocation = textContentStorage.textLocation(for: location) 52 | { 53 | if viewportRange == nil 54 | || viewportRange!.contains(textLocation) 55 | || viewportRange!.endLocation.compare(textLocation) == .orderedSame 56 | { 57 | drawBackgroundHighlight(within: rectWithinBounds, 58 | forLineContaining: textLocation, 59 | withColour: codeView?.theme.currentLineColour ?? .systemBackground) 60 | } 61 | } 62 | } 63 | } 64 | 65 | 66 | #elseif os(macOS) 67 | 68 | // MARK: - 69 | // MARK: Minimap view for macOS 70 | 71 | /// Customised text view for the minimap. 72 | /// 73 | class MinimapView: NSTextView { 74 | weak var codeView: CodeView? 75 | 76 | // Highlight the current line. 77 | // 78 | override func drawBackground(in rect: NSRect) { 79 | let rectWithinBounds = rect.intersection(bounds) 80 | super.drawBackground(in: rectWithinBounds) 81 | 82 | guard let textLayoutManager = textLayoutManager, 83 | let textContentStorage = textContentStorage 84 | else { return } 85 | 86 | let viewportRange = textLayoutManager.textViewportLayoutController.viewportRange 87 | 88 | // If the selection is an insertion point, highlight the corresponding line 89 | if let location = insertionPoint, 90 | let textLocation = textContentStorage.textLocation(for: location) 91 | { 92 | if viewportRange == nil 93 | || viewportRange!.contains(textLocation) 94 | || viewportRange!.endLocation.compare(textLocation) == .orderedSame 95 | { 96 | drawBackgroundHighlight(within: rectWithinBounds, 97 | forLineContaining: textLocation, 98 | withColour: codeView?.theme.currentLineColour ?? .textBackgroundColor) 99 | } 100 | } 101 | } 102 | } 103 | 104 | #endif 105 | 106 | 107 | // MARK: - 108 | // MARK: Minimap layout functionality 109 | 110 | class MinimapLineFragment: NSTextLineFragment { 111 | 112 | /// Text line fragment that we base our derived fragment on. 113 | /// 114 | /// `NSTextLineFragment` is a class cluster; hence, we need to embded a fragment generated by TextKit for us to get 115 | /// at its properties. 116 | /// 117 | private let textLineFragment: NSTextLineFragment 118 | 119 | /// All rendering attribute runs applying to this line. 120 | /// 121 | private let attributes: [MinimapLayoutFragment.AttributeRun] 122 | 123 | /// The advacement per glyph (for a monospaced font). 124 | /// 125 | private let advancement: CGFloat 126 | 127 | init(_ textLineFragment: NSTextLineFragment, attributes: [MinimapLayoutFragment.AttributeRun]) { 128 | self.textLineFragment = textLineFragment 129 | self.attributes = attributes 130 | 131 | let attributedString = textLineFragment.attributedString, 132 | range = textLineFragment.characterRange 133 | 134 | // Determine the advancement per glyph (assuming a monospaced font), scaling it down for the minimap. 135 | let font = if range.length > 0, 136 | let font = attributedString.attribute(.font, at: range.location, effectiveRange: nil) as? OSFont { font } 137 | else { OSFont.monospacedSystemFont(ofSize: OSFont.systemFontSize, weight: .regular) } 138 | advancement = font.maximumHorizontalAdvancement / minimapRatio 139 | 140 | super.init(attributedString: attributedString, range: range) 141 | } 142 | 143 | @available(*, unavailable) 144 | required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } 145 | 146 | override var glyphOrigin: CGPoint { CGPoint(x: textLineFragment.glyphOrigin.x / minimapRatio, 147 | y: textLineFragment.glyphOrigin.y / minimapRatio) } 148 | 149 | override var typographicBounds: CGRect { 150 | CGRect(x: textLineFragment.typographicBounds.minX / minimapRatio, 151 | y: textLineFragment.typographicBounds.minY / minimapRatio, 152 | width: textLineFragment.typographicBounds.width / minimapRatio, 153 | height: textLineFragment.typographicBounds.height / minimapRatio) 154 | } 155 | 156 | override func characterIndex(for point: CGPoint) -> Int { 157 | textLineFragment.characterIndex(for: CGPoint(x: point.x * minimapRatio, y: point.y * minimapRatio)) 158 | } 159 | 160 | override func fractionOfDistanceThroughGlyph(for point: CGPoint) -> CGFloat { 161 | textLineFragment.fractionOfDistanceThroughGlyph(for: point) 162 | } 163 | 164 | override func locationForCharacter(at index: Int) -> CGPoint { 165 | let point = textLineFragment.locationForCharacter(at: index) 166 | return CGPoint(x: point.x / minimapRatio, y: point.y / minimapRatio) 167 | } 168 | 169 | // Draw boxes using a character's foreground colour instead of actual glyphs. 170 | override func draw(at point: CGPoint, in context: CGContext) { 171 | 172 | // Leave some space between glyph boxes on adjacent lines 173 | let gap = typographicBounds.height * 0.3 174 | 175 | for attribute in attributes { 176 | 177 | let attributeRect = CGRect(x: floor(point.x + advancement * CGFloat(attribute.range.location)), 178 | y: floor(point.y + gap / 2), 179 | width: floor(advancement * CGFloat(attribute.range.length)), 180 | height: typographicBounds.height - gap) 181 | if let colour = attribute.attributes[.foregroundColor] as? OSColor { 182 | colour.withAlphaComponent(0.50).setFill() 183 | } 184 | OSBezierPath(rect: attributeRect).fill() 185 | } 186 | } 187 | } 188 | 189 | /// Minimap layout fragments replaces all line fragments by a our own variant of minimap line fragments, which draw 190 | /// coloured boxes instead of actual glyphs. 191 | /// 192 | class MinimapLayoutFragment: NSTextLayoutFragment { 193 | 194 | private var _textLineFragments: [NSTextLineFragment] = [] 195 | 196 | private var observation: NSKeyValueObservation? 197 | 198 | override var layoutFragmentFrame: CGRect { 199 | CGRect(x: super.layoutFragmentFrame.minX, 200 | y: super.layoutFragmentFrame.minY, 201 | width: super.layoutFragmentFrame.width / minimapRatio, 202 | height: super.layoutFragmentFrame.height / minimapRatio) 203 | } 204 | 205 | // NB: We don't override `renderingSurfaceBounds` as that is calculated on the basis of `layoutFragmentFrame`. 206 | 207 | @objc override dynamic var textLineFragments: [NSTextLineFragment] { 208 | return _textLineFragments 209 | } 210 | 211 | override init(textElement: NSTextElement, range rangeInElement: NSTextRange?) { 212 | super.init(textElement: textElement, range: rangeInElement) 213 | observation = super.observe(\.textLineFragments, options: [.new]){ [weak self] _, _ in 214 | 215 | // NB: We cannot use `change.newValue` as this seems to pull the value from the subclass property (which we 216 | // want to update here). Instead, we need to directly access `super`. This is, however as per Swift 5.9 217 | // not possible in a closure weakly capturing `self` (which we need to do here to avoid a retain cycle). 218 | // Hence, we defer to an auxilliary method. 219 | self?.updateTextLineFragments() 220 | } 221 | } 222 | 223 | typealias AttributeRun = (attributes: [NSAttributedString.Key : Any], range: NSRange) 224 | 225 | // We don't draw white space and control characters 226 | private let invisibleCharacterers = CharacterSet.whitespacesAndNewlines.union(CharacterSet.controlCharacters) 227 | private lazy var invertedInvisibleCharacters = invisibleCharacterers.inverted 228 | 229 | /// Update the text line fragments from the corresponding property of `super`. 230 | /// 231 | private func updateTextLineFragments() { 232 | if let textLayoutManager = self.textLayoutManager { 233 | 234 | var location = rangeInElement.location 235 | _textLineFragments = [] 236 | for fragment in super.textLineFragments { 237 | guard let string = (fragment.attributedString.string[fragment.characterRange].flatMap{ String($0) }) 238 | else { break } 239 | 240 | let attributeRuns 241 | = if let endLocation = textLayoutManager.location(location, offsetBy: fragment.characterRange.length), 242 | let textRange = NSTextRange(location: location, end: endLocation) 243 | { 244 | 245 | textLayoutManager.renderingAttributes(in: textRange).map { attributeRun in 246 | (attributes: attributeRun.attributes, 247 | range: NSRange(location: textLayoutManager.offset(from: location, to: attributeRun.textRange.location), 248 | length: textLayoutManager.offset(from: attributeRun.textRange.location, to: attributeRun.textRange.endLocation))) 249 | } 250 | } else { [AttributeRun]() } 251 | 252 | var attributeRunsWithoutWhitespace: [AttributeRun] = [] 253 | for (attributes, range) in attributeRuns { 254 | 255 | if attributes[.hideInvisibles] == nil { 256 | attributeRunsWithoutWhitespace.append((attributes: attributes, range: range)) 257 | } else { 258 | 259 | var remainingRange = range 260 | while remainingRange.length > 0, 261 | let match = string.rangeOfCharacter(from: invisibleCharacterers, range: remainingRange.range(in: string)) 262 | { 263 | 264 | let lower = match.lowerBound.utf16Offset(in: string), 265 | upper = min(match.upperBound.utf16Offset(in: string), remainingRange.max) 266 | 267 | // If we have got a prefix with visible characters, emit an attribute run covering those. 268 | if lower > remainingRange.location { 269 | attributeRunsWithoutWhitespace.append((attributes: attributes, 270 | range: NSRange(location: remainingRange.location, 271 | length: lower - remainingRange.location))) 272 | } 273 | 274 | // Advance the remaining range to after the character found in `match`. 275 | remainingRange = NSRange(location: upper, 276 | length: remainingRange.length - (upper - remainingRange.location)) 277 | 278 | if let nextVisibleCharacter = string.rangeOfCharacter(from: invertedInvisibleCharacters, 279 | range: remainingRange.range(in: string)) 280 | { 281 | 282 | // If there is another visible character, the new remaining range starts with that character. 283 | let lower = nextVisibleCharacter.lowerBound.utf16Offset(in: string) 284 | remainingRange = NSRange(location: lower, 285 | length: remainingRange.length - (lower - remainingRange.location)) 286 | 287 | } else { // If there are no more visible characters, we are done. 288 | remainingRange.length = 0 289 | } 290 | } 291 | } 292 | } 293 | _textLineFragments.append(MinimapLineFragment(fragment, attributes: attributeRunsWithoutWhitespace)) 294 | location = textLayoutManager.location(location, offsetBy: fragment.characterRange.length) ?? location 295 | } 296 | } 297 | } 298 | 299 | @available(*, unavailable) 300 | required init?(coder: NSCoder) { 301 | fatalError("init(coder:) has not been implemented") 302 | } 303 | } 304 | 305 | class MinimapTextLayoutManagerDelegate: NSObject, NSTextLayoutManagerDelegate { 306 | 307 | // We create instances of our own flavour of layout fragments 308 | func textLayoutManager(_ textLayoutManager: NSTextLayoutManager, 309 | textLayoutFragmentFor location: NSTextLocation, 310 | in textElement: NSTextElement) 311 | -> NSTextLayoutFragment 312 | { 313 | guard let paragraph = textElement as? NSTextParagraph 314 | else { return NSTextLayoutFragment(textElement: textElement, range: nil) } 315 | 316 | return MinimapLayoutFragment(textElement: paragraph, range: nil) 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/OSDefinitions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OSDefinitions.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 04/05/2021. 6 | // 7 | // A set of aliases and the like to smooth ove some superficial macOS/iOS differences. 8 | 9 | import SwiftUI 10 | 11 | #if os(iOS) || os(visionOS) 12 | 13 | import UIKit 14 | 15 | typealias OSFontDescriptor = UIFontDescriptor 16 | typealias OSFont = UIFont 17 | public 18 | typealias OSColor = UIColor 19 | typealias OSBezierPath = UIBezierPath 20 | typealias OSView = UIView 21 | typealias OSTextView = UITextView 22 | typealias OSHostingView = UIHostingView 23 | 24 | let labelColor = UIColor.label 25 | 26 | typealias TextStorageEditActions = NSTextStorage.EditActions 27 | 28 | extension UIFont { 29 | 30 | /// The constant adavance for a (horizontal) monospace font. 31 | /// 32 | var maximumHorizontalAdvancement: CGFloat { 33 | let ctFont = CTFontCreateWithFontDescriptor(fontDescriptor, pointSize, nil), 34 | xGlyph = [CTFontGetGlyphWithName(ctFont, "x" as NSString)] 35 | return CTFontGetAdvancesForGlyphs(ctFont, .horizontal, xGlyph, nil, 1) 36 | } 37 | } 38 | 39 | extension UIColor { 40 | 41 | /// Create a UIKit colour from a SwiftUI colour if possible. 42 | /// 43 | convenience init?(color: Color) { 44 | guard let cgColor = color.cgColor else { return nil } 45 | self.init(cgColor: cgColor) 46 | } 47 | } 48 | 49 | extension UIView { 50 | 51 | /// Add a subview such that it is layered below its siblings. 52 | /// 53 | /// - Parameter view: The subview to add. 54 | /// 55 | func addBackgroundSubview(_ view: UIView) { 56 | addSubview(view) 57 | sendSubviewToBack(view) 58 | } 59 | } 60 | 61 | 62 | #elseif os(macOS) 63 | 64 | import AppKit 65 | 66 | typealias OSFontDescriptor = NSFontDescriptor 67 | typealias OSFont = NSFont 68 | public 69 | typealias OSColor = NSColor 70 | typealias OSBezierPath = NSBezierPath 71 | typealias OSView = NSView 72 | typealias OSTextView = NSTextView 73 | typealias OSHostingView = NSHostingView 74 | 75 | let labelColor = NSColor.labelColor 76 | 77 | typealias TextStorageEditActions = NSTextStorageEditActions 78 | 79 | extension NSFont { 80 | 81 | /// The constant adavance for a (horizontal) monospace font. 82 | /// 83 | var maximumHorizontalAdvancement: CGFloat { self.maximumAdvancement.width } 84 | 85 | /// The line height (which is an exting property on `UIFont`). 86 | /// 87 | var lineHeight: CGFloat { ceil(ascender - descender - leading) } 88 | } 89 | extension NSColor { 90 | 91 | /// Create an AppKit colour from a SwiftUI colour if possible. 92 | /// 93 | convenience init?(color: Color) { 94 | guard let cgColor = color.cgColor else { return nil } 95 | self.init(cgColor: cgColor) 96 | } 97 | } 98 | 99 | extension NSView { 100 | 101 | /// Add a subview such that it is layered below its siblings. 102 | /// 103 | /// - Parameter view: The subview to add. 104 | /// 105 | func addBackgroundSubview(_ view: NSView) { 106 | addSubview(view, positioned: .below, relativeTo: nil) 107 | } 108 | 109 | /// Imitate UIKit interface. 110 | /// 111 | func insertSubview(_ view: NSView, aboveSubview siblingSubview: NSView) { 112 | addSubview(view, positioned: .above, relativeTo: siblingSubview) 113 | } 114 | 115 | /// Imitate UIKit interface. 116 | /// 117 | func insertSubview(_ view: NSView, belowSubview siblingSubview: NSView) { 118 | addSubview(view, positioned: .below, relativeTo: siblingSubview) 119 | } 120 | } 121 | 122 | #endif 123 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/ScrollViewExtras.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrollView.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 27/11/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | #if os(iOS) || os(visionOS) 12 | 13 | // MARK: - 14 | // MARK: UIKit version 15 | 16 | extension UIScrollView { 17 | 18 | var verticalScrollPosition: CGFloat { 19 | get { contentOffset.y } 20 | set { 21 | let newOffset = max(0, min(newValue, bounds.height - contentSize.height)) 22 | if abs(newOffset - contentOffset.y) > 0.0001 { 23 | setContentOffset(CGPoint(x: contentOffset.x, y: newOffset), animated: false) 24 | } 25 | } 26 | } 27 | } 28 | 29 | 30 | #elseif os(macOS) 31 | 32 | // MARK: - 33 | // MARK: AppKit version 34 | 35 | extension NSScrollView { 36 | 37 | @MainActor 38 | var verticalScrollPosition: CGFloat { 39 | get { documentVisibleRect.origin.y } 40 | set { 41 | 42 | (documentView as? CodeView)?.textLayoutManager?.textViewportLayoutController.layoutViewport() 43 | 44 | let newOffset = max(0, min(newValue, (documentView?.bounds.height ?? 0) - contentSize.height)) 45 | if abs(newOffset - documentVisibleRect.origin.y) > 0.0001 { 46 | contentView.scroll(to: CGPoint(x: documentVisibleRect.origin.x, y: newOffset)) 47 | } 48 | 49 | // This is necessary as the floating subviews are otherwise *sometimes* not correctly re-positioned. 50 | reflectScrolledClipView(contentView) 51 | 52 | } 53 | } 54 | } 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/TextContentStorageExtras.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextContentStorageExtras.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 02/10/2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | extension NSTextContentStorage { 12 | 13 | /// Convert a text location to a character location within this text content storage. 14 | /// 15 | /// - Parameter textLocation: The text location to convert. 16 | /// - Returns: The corresponding character position in the underlying text storage. 17 | /// 18 | func location(for textLocation: NSTextLocation) -> Int { 19 | offset(from: documentRange.location, to: textLocation) 20 | } 21 | 22 | /// Convert a character location into a text location within this text content storage. 23 | /// 24 | /// - Parameter location: The character location to convert. 25 | /// - Returns: The corresponding text location. 26 | /// 27 | func textLocation(for location: Int) -> NSTextLocation? { 28 | self.location(documentRange.location, offsetBy: location) 29 | } 30 | 31 | /// Convert a text range to a character range within this text content storage. 32 | /// 33 | /// - Parameter textRange: The text range to convert. 34 | /// - Returns: The corresponding character range in the underlying text storage. 35 | /// 36 | func range(for textRange: NSTextRange) -> NSRange { 37 | NSRange(location: offset(from: documentRange.location, to: textRange.location), 38 | length: offset(from: textRange.location, to: textRange.endLocation)) 39 | } 40 | 41 | /// Convert a character range to a text range within this text content storage. 42 | /// 43 | /// - Parameter range: The character range to convert. 44 | /// - Returns: The corresponding text range in the underlying text storage if there exists a corresponding valid text 45 | /// range. 46 | /// 47 | func textRange(for range: NSRange) -> NSTextRange? { 48 | // NB: `NSTextRange(location:end:)` crashes if `end` is before `start` (instead of returning `nil`). 49 | guard range.length >= 0 else { return nil } 50 | 51 | if let start = location(documentRange.location, offsetBy: range.location), 52 | let end = location(start, offsetBy: range.length) 53 | { 54 | return NSTextRange(location: start, end: end) 55 | } else { return nil } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/TextLayoutManagerExtras.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextLayoutManagerExtras.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 01/10/2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | // MARK: - 12 | // MARK: 'NSTextLayoutFragment' extras 13 | 14 | extension NSTextLayoutFragment { 15 | 16 | /// Yield the layout fragment's frame, but without the height of an extra line fragment if present. 17 | /// 18 | var layoutFragmentFrameWithoutExtraLineFragment: CGRect { 19 | var frame = layoutFragmentFrame 20 | 21 | // If this layout fragment's last line fragment is for an empty string, then it is an extra line fragment and we 22 | // deduct its height from the layout fragment's height. 23 | if let lastTextLineFragment = textLineFragments.last, lastTextLineFragment.characterRange.length == 0 { 24 | frame.size.height -= lastTextLineFragment.typographicBounds.height 25 | } 26 | return frame 27 | } 28 | 29 | /// This is a temporary kludge to fix the height of the extra line fragment in case the size of the font used in the 30 | /// rest of the layout fragment varies from the standard font size. In TextKit 2, it is far from clear how to 31 | /// indicate the metrics to be used in a nicer manner. (Just setting the default font of the text view doesn't seem 32 | /// to work.) 33 | /// 34 | /// The solution here only works if there is at least one other line fragment (but that's always the case if the 35 | /// displayed string is now empty) and we use them same font height everywhere (which is the case for a code view). 36 | /// We simply use height of another line fragement for that of the extra line fragment and adjust the overall frame 37 | /// accordingly. 38 | /// 39 | var layoutFragmentFrameAdjustedKludge: CGRect { 40 | var frame = layoutFragmentFrame 41 | 42 | // If this layout fragment's last line fragment is for an empty string, then it is an extra line fragment and we 43 | // deduct its height from the layout fragment's height. 44 | if let firstTextLineFragment = textLineFragments.first, 45 | let lastTextLineFragment = textLineFragments.last, 46 | lastTextLineFragment.characterRange.length == 0 47 | { 48 | frame.size.height -= lastTextLineFragment.typographicBounds.height 49 | frame.size.height += firstTextLineFragment.typographicBounds.height 50 | } 51 | return frame 52 | } 53 | 54 | /// Yield the frame of the layout fragment's extra line fragment if present (which is the case if this the last 55 | /// line fragment and it is terminated by a newline character). 56 | /// 57 | var layoutFragmentFrameExtraLineFragment: CGRect? { 58 | 59 | // If this layout fragment's last line fragment is for an empty string, then it is an extra line fragment and 60 | // return its bounds. 61 | if let lastTextLineFragment = textLineFragments.last, lastTextLineFragment.characterRange.length == 0 { 62 | let height = lastTextLineFragment.typographicBounds.height 63 | return CGRect(x: layoutFragmentFrame.minX, 64 | y: layoutFragmentFrame.maxY - height, 65 | width: layoutFragmentFrame.width, 66 | height: height) 67 | } else { 68 | return nil 69 | } 70 | } 71 | } 72 | 73 | 74 | // MARK: - 75 | // MARK: 'NSTextLayoutManager' extras 76 | 77 | extension NSTextLayoutManager { 78 | 79 | /// Yield the height of the entire set of text fragments covering the given text range. 80 | /// 81 | /// - Parameter textRange: The text range for which we want to compute the height of the text fragments. 82 | /// - Returns: The height of the text fragments. 83 | /// 84 | /// If there are gaps, they are included. If the range reaches until the end of the text and there is extra line 85 | /// fragment, then it is included, too. 86 | /// 87 | func textLayoutFragmentExtent(for textRange: NSTextRange) -> (y: CGFloat, height: CGFloat)? { 88 | let location = textRange.location 89 | 90 | if location.compare(documentRange.endLocation) == .orderedSame { // Start of range == end of the document 91 | 92 | if let lastLocation = textContentManager?.location(textRange.endLocation, offsetBy: -1), 93 | let lastTextLayoutFragment = textLayoutFragment(for: lastLocation), 94 | let lastTextLineFragment = lastTextLayoutFragment.textLineFragments.last, 95 | lastTextLineFragment.characterRange.length == 0 96 | { // trailing newline at the end of the document 97 | 98 | // Extra line fragement 99 | let typographicBounds = lastTextLineFragment.typographicBounds 100 | return (y: lastTextLayoutFragment.layoutFragmentFrame.minY + typographicBounds.minY, 101 | height: typographicBounds.height) 102 | 103 | } else { // no trailing newline at the end of the document 104 | 105 | if let endLocation = textContentManager?.location(textRange.endLocation, offsetBy: -1) , 106 | let endFrame = textLayoutFragment(for: endLocation)?.layoutFragmentFrame 107 | { 108 | 109 | return (y: endFrame.minY, height: endFrame.height) 110 | 111 | } else { return nil } 112 | 113 | } 114 | 115 | } else { 116 | 117 | let endLocation = if textRange.isEmpty { nil as NSTextLocation? } 118 | else { textContentManager?.location(textRange.endLocation, offsetBy: -1) }, 119 | startFragment = textLayoutFragment(for: location), 120 | startFrame = startFragment?.layoutFragmentFrame, 121 | endFragment = if let endLocation { textLayoutFragment(for: endLocation) } 122 | else { nil as NSTextLayoutFragment? }, 123 | endFrame = endFragment?.layoutFragmentFrameAdjustedKludge 124 | 125 | switch (startFrame, endFrame) { 126 | case (nil, nil): return nil 127 | case (.some(let startFrame), nil): return (y: startFrame.minY, height: startFrame.height) 128 | case (nil, .some(let endFrame)): return (y: endFrame.minY, height: endFrame.height) 129 | 130 | case (.some(let startFrame), .some(let endFrame)): 131 | if startFrame.minY < endFrame.minY { 132 | return (y: startFrame.minY, height: endFrame.maxY - startFrame.minY) 133 | } else { 134 | return (y: endFrame.minY, height: startFrame.maxY - endFrame.minY) 135 | } 136 | } 137 | } 138 | } 139 | 140 | /// Enumerate all the text layout fragments that lie (partly) in the given range. 141 | /// 142 | /// - Parameters: 143 | /// - range: The range for which we want to enumerate the text layout fragments. 144 | /// - options: Enumeration options. 145 | /// - block: The block to invoke on each eumerated text layout fragment. 146 | /// - Returns: See `NSTextLayoutFragment.enumerateTextLayoutFragments(from:options:using:)`. 147 | /// 148 | @discardableResult 149 | func enumerateTextLayoutFragments(in textRange: NSTextRange, 150 | options: NSTextLayoutFragment.EnumerationOptions = [], 151 | using block: (NSTextLayoutFragment) -> Bool) 152 | -> NSTextLocation? 153 | { 154 | // FIXME: This doesn't work if the options include `.reverse`. 155 | enumerateTextLayoutFragments(from: textRange.location, options: options) { textLayoutFragment in 156 | textLayoutFragment.rangeInElement.location.compare(textRange.endLocation) == .orderedAscending 157 | && block(textLayoutFragment) 158 | } 159 | } 160 | 161 | /// Compute the smallest rect that encompasses all text layout fragments that (partly) lie in the given range. 162 | /// 163 | /// - Parameter textRange: The range for which we want to compute the bounding box. 164 | /// - Returns: The bounding box. 165 | /// 166 | func textLayoutFragmentBoundingRect(for textRange: NSTextRange) -> CGRect { 167 | 168 | var boundingBox: CGRect = .null 169 | enumerateTextLayoutFragments(in: textRange, options: [.ensuresExtraLineFragment]) { textLayoutFragment in 170 | boundingBox = boundingBox.union(textLayoutFragment.layoutFragmentFrame) 171 | return true 172 | } 173 | return boundingBox 174 | } 175 | 176 | /// Determine the bounding rect of the first text segment of a given text range. 177 | /// 178 | /// - Parameter textRange: The text range for which we want to determine the first segment. 179 | /// - Returns: The bounding rect of the first text segment if any. 180 | /// 181 | func boundingRectOfFirstTextSegment(for textRange: NSTextRange) -> CGRect? { 182 | var result: CGRect? 183 | enumerateTextSegments(in: textRange, type: .standard, options: .rangeNotRequired) { (_, rect, _, _) in 184 | result = rect 185 | return false 186 | } 187 | return result 188 | } 189 | 190 | /// Enumerates the rendering attributes within a given range. 191 | /// 192 | /// - Parameters: 193 | /// - textRange: The text range whose rendering attributes are to be enumerated. 194 | /// - reverse: Whether to enumerate in reverse; i.e., right-to-left. 195 | /// - block: A closure invoked for each attribute run within the range. 196 | /// 197 | func enumerateRenderingAttributes(in textRange: NSTextRange, 198 | reverse: Bool, 199 | using block: (NSTextLayoutManager, [NSAttributedString.Key : Any], NSTextRange) -> Void) 200 | { 201 | if !reverse { 202 | 203 | enumerateRenderingAttributes(from: textRange.location, reverse: false) { textLayoutManager, attributes, attributeRange in 204 | 205 | if let clippedRange = attributeRange.intersection(textRange) { 206 | block(textLayoutManager, attributes, clippedRange) 207 | } 208 | return attributeRange.endLocation.compare(textRange.endLocation) == .orderedAscending 209 | } 210 | 211 | } else { 212 | 213 | enumerateRenderingAttributes(from: textRange.endLocation, reverse: true) { textLayoutManager, attributes, attributeRange in 214 | 215 | if let clippedRange = attributeRange.intersection(textRange) { 216 | block(textLayoutManager, attributes, clippedRange) 217 | } 218 | return attributeRange.location.compare(textRange.location) == .orderedDescending 219 | } 220 | 221 | } 222 | } 223 | 224 | /// A set of string attributes together with a text range to which they apply. 225 | /// 226 | typealias AttributeRun = (attributes: [NSAttributedString.Key : Any], textRange: NSTextRange) 227 | 228 | /// Collect all rendering attributes and their character ranges within a given text range. 229 | /// 230 | /// - Parameter textRange: The text range in which we want to collect rendering attributes. 231 | /// - Returns: An array of pairs and associated range for all rendering attributes within the given text range. 232 | /// 233 | func renderingAttributes(in textRange: NSTextRange) -> [AttributeRun] { 234 | 235 | var attributes: [(attributes: [NSAttributedString.Key : Any], textRange: NSTextRange)] = [] 236 | enumerateRenderingAttributes(in: textRange, reverse: false) { attributes.append((attributes: $1, textRange: $2)) } 237 | return attributes 238 | } 239 | } 240 | 241 | 242 | // MARK: - 243 | // MARK: 'NSTextLayoutManager' workaround 244 | 245 | // There appears to be a bug in the implementation of 'NSTextLayoutManager' on at least macOS 14. Specifically, during 246 | // processing of an edit operation, a closure stored in `renderingAttributesValidator` gets called before the layout 247 | // manager has updated its data structures to text locations *after* the edit. As a result, calls to 248 | // `setRenderingAttributes(_:for:)` (and related methods) will set rendering attributes in the vicinity of the edit 249 | // location for shifted character ranges (if the edit operation changes the length of the text). 250 | // 251 | // It turns out that we can work around this issue by delaying the calls to `setRenderingAttributes(_:for:)` until after 252 | // `NSTextContentManager.hasEditingTransaction` is `false`. We achieve this by using KVO to observe 253 | // `hasEditingTransaction`, in order to trigger attribute setting after the editing transaction has completed. 254 | // 255 | // Unfortunately, the fix involving `NSTextContentManager.hasEditingTransaction`, on its own, is not sufficient, as 256 | // `hasEditingTransaction` is not being used in case of a a paste command or in case of an undo/redo. In these cases, 257 | // we wait until the `CodeViewDelegate` receives a `textDidChange` notification to trigger setting the attributes. 258 | 259 | extension NSTextLayoutManager { 260 | 261 | private final class PendingTextLayoutFragments { 262 | var fragments: [NSTextLayoutFragment] = [] 263 | } 264 | 265 | /// Set the rendering attribute validator in a way that it avoids the timing bug with updating internal layout 266 | /// manager structures on (at least) macOS 14. 267 | /// 268 | /// - Parameters: 269 | /// - codeViewDelegate: The code view delegate whose `textDidChange` hook ought to be used for setting attributes. 270 | /// - renderingAttributesValidator: The validator to set. 271 | /// - Returns: A KVO object that needs to be retained until this validator is no longer needed. 272 | /// 273 | func setSafeRenderingAttributesValidator(with codeViewDelegate: CodeViewDelegate, 274 | _ renderingAttributesValidator: 275 | @escaping (NSTextLayoutManager, NSTextLayoutFragment) -> Void) 276 | -> NSKeyValueObservation? 277 | { 278 | guard let textContentManager else { 279 | 280 | self.renderingAttributesValidator = renderingAttributesValidator 281 | return nil 282 | 283 | } 284 | 285 | let pendingFragments = PendingTextLayoutFragments() 286 | 287 | self.renderingAttributesValidator = { textLayoutManager, textLayoutFragment in 288 | 289 | // We delay setting attributes, except if the entire text has been replaced by a new one. In that case, it is 290 | // fine to set the attributes right away. 291 | if let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage, 292 | let codeStorageDelegate = textContentStorage.textStorage?.delegate as? CodeStorageDelegate, 293 | codeStorageDelegate.processingStringReplacement 294 | { 295 | renderingAttributesValidator(textLayoutManager, textLayoutFragment) 296 | } else { 297 | pendingFragments.fragments.append(textLayoutFragment) 298 | } 299 | } 300 | 301 | func processFragements() { 302 | let fragments = pendingFragments.fragments 303 | pendingFragments.fragments = [] 304 | fragments.forEach { 305 | renderingAttributesValidator(self, $0) } 306 | } 307 | 308 | // After a text change is reported to the view's delegate, always process all pending fragments. 309 | let currentTextDidChange = codeViewDelegate.textDidChange 310 | codeViewDelegate.textDidChange = { textView in 311 | currentTextDidChange?(textView) 312 | processFragements() 313 | } 314 | 315 | return textContentManager.observe(\.hasEditingTransaction) { 316 | [processFragements] textContentManager, change in 317 | 318 | // Process fragments if an editing transaction just ended. 319 | guard !textContentManager.hasEditingTransaction else { return } 320 | processFragements() 321 | 322 | } 323 | } 324 | 325 | /// Notifies the layout manager that the old rendering attributes in the given range need to be invalidated and 326 | /// sets new rendering attributes. 327 | /// 328 | /// - Parameter for: The range whose attributes need to be validated and redeisplayed. 329 | /// 330 | /// TODO: This may be a large range. In this case, the work should maybe be cut up to avoid unresponsiveness. 331 | /// 332 | func redisplayRenderingAttributes(for textRange: NSTextRange) { 333 | invalidateRenderingAttributes(for: textRange) 334 | enumerateTextLayoutFragments(in: textRange) { textLayoutFragment in 335 | 336 | renderingAttributesValidator?(self, textLayoutFragment) 337 | return true 338 | } 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/TextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextView.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 28/09/2020. 6 | // 7 | // Text view protocol that extracts common functionality between 'UITextView' and 'NSTextView'. 8 | 9 | import Foundation 10 | 11 | import LanguageSupport 12 | 13 | 14 | // MARK: - 15 | // MARK: The protocol 16 | 17 | /// A protocol that bundles up the commonalities of 'UITextView' and 'NSTextView'. 18 | /// 19 | protocol TextView { 20 | associatedtype Color 21 | associatedtype Font 22 | 23 | // This is necessary as these members are optional in AppKit and not optional in UIKit. 24 | var optTextContainer: NSTextContainer? { get } 25 | var optCodeStorage: CodeStorage? { get } 26 | 27 | // FIXME: Get rid of the `opt`-prefix. It's not necessary for TextKit 2 objects. 28 | var optTextLayoutManager: NSTextLayoutManager? { get } 29 | var optTextContentStorage: NSTextContentStorage? { get } 30 | 31 | var textBackgroundColor: Color? { get } 32 | var textFont: Font? { get } 33 | var textContainerOrigin: CGPoint { get } 34 | 35 | /// The text displayed by the text view. 36 | /// 37 | var text: String! { get set } 38 | 39 | /// If the current selection is an insertion point (i.e., the selection length is 0), return its location. 40 | /// 41 | var insertionPoint: Int? { get } 42 | 43 | /// The current (single range) selection of the text view. 44 | /// 45 | var selectedRange: NSRange { get set } 46 | 47 | /// The set of lines that have characters that are included in the current selection. (This may be a multi-selection, 48 | /// and hence, a non-contiguous range.) 49 | /// 50 | var selectedLines: Set { get } 51 | 52 | /// The bounds of the view. 53 | /// 54 | var bounds: CGRect { get set } 55 | 56 | /// The visible portion of the text view. (This only accounts for portions of the text view that are obscured through 57 | /// visibility in a scroll view.) 58 | /// 59 | var documentVisibleRect: CGRect { get } 60 | 61 | /// The size of the whole document (after layout). 62 | /// 63 | var contentSize: CGSize { get } 64 | 65 | /// Temporarily highlight the visible part of the given range. 66 | /// 67 | func showFindIndicator(for range: NSRange) 68 | 69 | /// Marks the given rectangle of the current view as needing redrawing. 70 | /// 71 | func setNeedsDisplay(_ invalidRect: CGRect) 72 | } 73 | 74 | 75 | // MARK: Shared code 76 | 77 | extension TextView { 78 | 79 | /// The text view's line map. 80 | /// 81 | var optLineMap: LineMap? { 82 | return (optCodeStorage?.delegate as? CodeStorageDelegate)?.lineMap 83 | } 84 | 85 | /// The text view's language service. 86 | /// 87 | var optLanguageService: LanguageService? { 88 | return (optCodeStorage?.delegate as? CodeStorageDelegate)?.languageService 89 | } 90 | 91 | /// Determine the visible range of lines. 92 | /// 93 | var documentVisibleLines: Range? { 94 | guard let textLayoutManager = optTextLayoutManager, 95 | let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage, 96 | let lineMap = (optCodeStorage?.delegate as? CodeStorageDelegate)?.lineMap, 97 | lineMap.lines.count > 1 // this ensure that the line map has been initialised 98 | else { return nil } 99 | 100 | if let textRange = textLayoutManager.textViewportLayoutController.viewportRange { 101 | return lineMap.linesOf(range: textContentStorage.range(for: textRange)) 102 | } else { return nil } 103 | } 104 | 105 | /// Determine the bounding rectangle for fragments containing the characters in the given range. 106 | /// 107 | /// - Parameter range: The range of characters whose fragment bounding rectangle we desire. If nil, the entire text is 108 | /// used. 109 | /// - Returns: The bounding rectangle if the character range is valid. The coordinates are relative to the origin of 110 | /// the text view. 111 | /// 112 | func fragmentBoundingRect(for range: NSRange? = nil) -> CGRect? { 113 | guard let textLayoutManager = optTextLayoutManager, 114 | let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage 115 | else { return nil } 116 | 117 | let textRange = if let range, 118 | let textRange = textContentStorage.textRange(for: range) { textRange } 119 | else { textContentStorage.documentRange }, 120 | rect = textLayoutManager.textLayoutFragmentBoundingRect(for: textRange) 121 | return rect.offsetBy(dx: textContainerOrigin.x, dy: textContainerOrigin.y) 122 | } 123 | 124 | /// Invalidate the entire background area of the line containing the given text location. 125 | /// 126 | /// - Parameter textLocation: The text location whose line we want to invalidate. 127 | /// 128 | func invalidateBackground(forLineContaining textLocation: NSTextLocation) { 129 | invalidateBackground(forLinesContaining: NSTextRange(location: textLocation)) 130 | } 131 | 132 | /// Invalidate the entire background area of the lines containing the given text range. 133 | /// 134 | /// - Parameter textRange: The text ranges whose lines we want to invalidate. 135 | /// 136 | func invalidateBackground(forLinesContaining textRange: NSTextRange) { 137 | 138 | guard let textLayoutManager = optTextLayoutManager else { return } 139 | 140 | if let (y: y, height: height) = textLayoutManager.textLayoutFragmentExtent(for: textRange), 141 | let invalidRect = lineBackgroundRect(y: y, height: height) 142 | { 143 | if textRange.endLocation.compare(textLayoutManager.documentRange.endLocation) == .orderedSame { 144 | setNeedsDisplay(CGRect(origin: invalidRect.origin, size: CGSize(width: invalidRect.width, height: bounds.height - invalidRect.minY))) 145 | } else { 146 | setNeedsDisplay(invalidRect) 147 | } 148 | } 149 | } 150 | 151 | /// Draw the background of an entire line of text with a highlight colour. 152 | /// 153 | func drawBackgroundHighlight(within rect: CGRect, 154 | forLineContaining textLocation: NSTextLocation, 155 | withColour colour: OSColor) 156 | { 157 | guard let textLayoutManager = optTextLayoutManager else { return } 158 | 159 | colour.setFill() 160 | if let fragmentFrame = textLayoutManager.textLayoutFragment(for: textLocation)?.layoutFragmentFrameWithoutExtraLineFragment, 161 | let highlightRect = lineBackgroundRect(y: fragmentFrame.minY, height: fragmentFrame.height) 162 | { 163 | 164 | let clippedRect = highlightRect.intersection(rect) 165 | if !clippedRect.isNull { OSBezierPath(rect: clippedRect).fill() } 166 | 167 | } else 168 | if let previousLocation = optTextContentStorage?.location(textLocation, offsetBy: -1), 169 | let fragmentFrame = textLayoutManager.textLayoutFragment(for: previousLocation)?.layoutFragmentFrameExtraLineFragment, 170 | let highlightRect = lineBackgroundRect(y: fragmentFrame.minY, height: fragmentFrame.height) 171 | { 172 | 173 | let clippedRect = highlightRect.intersection(rect) 174 | if !clippedRect.isNull { OSBezierPath(rect: clippedRect).fill() } 175 | 176 | } 177 | } 178 | 179 | /// Compute the background rect from the extent of a line's fragement rect. On lines that contain a message view, the 180 | /// fragment rect doesn't cover the entire background. We, moreover, need to account for the space between the text 181 | /// container's right hand side and the divider of the minimap (if the minimap is visible). 182 | /// 183 | func lineBackgroundRect(y: CGFloat, height: CGFloat) -> CGRect? { 184 | 185 | // We start at x = 0 as it looks nicer in case we overscoll when horizontal scrolling is enabled (i.e., when lines 186 | // are not wrapped). 187 | return CGRect(x: 0, y: y, width: bounds.size.width, height: height) 188 | } 189 | } 190 | 191 | 192 | #if os(iOS) || os(visionOS) 193 | 194 | // MARK: - 195 | // MARK: UIKit version 196 | 197 | import UIKit 198 | 199 | private let highlightingAttributes = [NSAttributedString.Key.foregroundColor: UIColor.black, 200 | NSAttributedString.Key.backgroundColor: UIColor.yellow] 201 | 202 | extension UITextView: TextView { 203 | typealias Color = UIColor 204 | typealias Font = UIFont 205 | 206 | var optTextLayoutManager: NSTextLayoutManager? { textLayoutManager } 207 | var optTextContainer: NSTextContainer? { textContainer } 208 | var optTextContentStorage: NSTextContentStorage? { textLayoutManager?.textContentManager as? NSTextContentStorage } 209 | var optCodeStorage: CodeStorage? { textStorage as? CodeStorage } 210 | 211 | var textBackgroundColor: Color? { backgroundColor } 212 | var textFont: Font? { font } 213 | var textContainerOrigin: CGPoint { return CGPoint(x: textContainerInset.left, y: textContainerInset.top) } 214 | 215 | var insertionPoint: Int? { selectedRange.length == 0 ? selectedRange.location : nil } 216 | 217 | var selectedLines: Set { 218 | guard let codeStorageDelegate = optCodeStorage?.delegate as? CodeStorageDelegate else { return Set() } 219 | 220 | return Set(codeStorageDelegate.lineMap.linesContaining(range: selectedRange)) 221 | } 222 | 223 | var documentVisibleRect: CGRect { return CGRect(origin: contentOffset, size: bounds.size) } 224 | 225 | // This implementation currently comes with an infelicity. If there is already a indicator view visible, while this 226 | // method is called again, the old view should be removed right away. This is a bit awkward to implement, as we cannot 227 | // add a stored property in an extension, but it should happen eventually as it does look better. 228 | func showFindIndicator(for range: NSRange) { 229 | guard let textLayoutManager = optTextLayoutManager, 230 | let textContentStorage = textLayoutManager.textContentManager as? NSTextContentStorage, 231 | let visibleTextRange = textLayoutManager.textViewportLayoutController.viewportRange 232 | else { return } 233 | 234 | // Determine the visible portion of the range 235 | let visibleCharRange = textContentStorage.range(for: visibleTextRange), 236 | visibleRange = NSIntersectionRange(visibleCharRange, range) 237 | 238 | // Set up a label view to animate as the indicator view 239 | guard let visibleTextRange = textContentStorage.textRange(for: visibleRange), 240 | let rect = textLayoutManager.boundingRectOfFirstTextSegment(for: visibleTextRange) 241 | else { return } 242 | let label = UILabel(frame: rect.offsetBy(dx: textContainerOrigin.x, dy: 0)), 243 | text = NSMutableAttributedString(attributedString: textStorage.attributedSubstring(from: visibleRange)) 244 | text.addAttributes(highlightingAttributes, range: NSRange(location: 0, length: text.length)) 245 | label.attributedText = text 246 | label.layer.cornerRadius = 3 247 | label.layer.masksToBounds = true 248 | addSubview(label) 249 | 250 | // We animate the label in with a spring effect, and remove it with a delay. 251 | label.alpha = 0 252 | label.transform = CGAffineTransform(scaleX: 0.2, y: 0.2) 253 | UIView.animate(withDuration: 0.2, delay: 0, usingSpringWithDamping: 0.1, initialSpringVelocity: 1){ 254 | label.alpha = 1 255 | label.transform = CGAffineTransform.identity 256 | } completion: { _ in 257 | UIView.animate(withDuration: 0.2, delay: 0.4){ 258 | label.alpha = 0 259 | } completion: { _ in 260 | label.removeFromSuperview() 261 | } 262 | } 263 | } 264 | } 265 | 266 | 267 | #elseif os(macOS) 268 | 269 | // MARK: - 270 | // MARK: AppKit version 271 | 272 | import AppKit 273 | 274 | extension NSTextView: TextView { 275 | typealias Color = NSColor 276 | typealias Font = NSFont 277 | 278 | var optTextLayoutManager: NSTextLayoutManager? { textLayoutManager } 279 | var optTextContainer: NSTextContainer? { textContainer } 280 | var optTextContentStorage: NSTextContentStorage? { textContentStorage } 281 | var optCodeStorage: CodeStorage? { textStorage as? CodeStorage } 282 | 283 | var textBackgroundColor: Color? { backgroundColor } 284 | var textFont: Font? { font } 285 | var textContainerOrigin: CGPoint { return CGPoint(x: textContainerInset.width, y: textContainerInset.height) } 286 | 287 | var text: String! { 288 | get { string } 289 | set { string = newValue } 290 | } 291 | 292 | var insertionPoint: Int? { 293 | if let selection = selectedRanges.first as? NSRange, selection.length == 0 { return selection.location } 294 | else { return nil } 295 | } 296 | 297 | var selectedLines: Set { 298 | guard let codeStorageDelegate = optCodeStorage?.delegate as? CodeStorageDelegate else { return Set() } 299 | 300 | let lineRanges: [Range] = selectedRanges.map{ range in 301 | if let range = range as? NSRange { return codeStorageDelegate.lineMap.linesContaining(range: range) } 302 | else { return 0..<0 } 303 | } 304 | return lineRanges.reduce(Set()){ $0.union($1) } 305 | } 306 | 307 | var documentVisibleRect: CGRect { enclosingScrollView?.documentVisibleRect ?? bounds } 308 | 309 | var contentSize: CGSize { bounds.size } 310 | } 311 | 312 | #endif 313 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 14/05/2021. 6 | // 7 | // This module defines code highlight themes. 8 | 9 | import SwiftUI 10 | 11 | 12 | /// A code highlighting theme. Different syntactic elements are purely distinguished by colour. 13 | /// 14 | /// NB: Themes are `Identifiable`. To ensure that a theme's identity changes when any of its properties is being changed 15 | /// the `id` is updated on setting any property. This makes mutating properties fairly expensive, but it should also 16 | /// not be a particularily frequent operation. 17 | /// 18 | public struct Theme: Identifiable { 19 | public private(set) var id = UUID() 20 | 21 | /// The colour scheme of the theme. 22 | /// 23 | public var colourScheme: ColorScheme { 24 | didSet { id = UUID() } 25 | } 26 | 27 | /// The name of the font to use. 28 | /// 29 | public var fontName: String { 30 | didSet { id = UUID() } 31 | } 32 | 33 | /// The point size of the font to use. 34 | /// 35 | public var fontSize: CGFloat { 36 | didSet { id = UUID() } 37 | } 38 | 39 | /// The default foreground text colour. 40 | /// 41 | public var textColour: OSColor { 42 | didSet { id = UUID() } 43 | } 44 | 45 | /// The colour for (all kinds of) comments. 46 | /// 47 | public var commentColour: OSColor { 48 | didSet { id = UUID() } 49 | } 50 | 51 | /// The colour for string literals. 52 | /// 53 | public var stringColour: OSColor { 54 | didSet { id = UUID() } 55 | } 56 | 57 | /// The colour for character literals. 58 | /// 59 | public var characterColour: OSColor { 60 | didSet { id = UUID() } 61 | } 62 | 63 | /// The colour for number literals. 64 | /// 65 | public var numberColour: OSColor { 66 | didSet { id = UUID() } 67 | } 68 | 69 | /// The colour for identifiers. 70 | /// 71 | public var identifierColour: OSColor { 72 | didSet { id = UUID() } 73 | } 74 | 75 | /// The colour for operators. 76 | /// 77 | public var operatorColour: OSColor { 78 | didSet { id = UUID() } 79 | } 80 | 81 | /// The colour for keywords. 82 | /// 83 | public var keywordColour: OSColor { 84 | didSet { id = UUID() } 85 | } 86 | 87 | /// The colour for reserved symbols. 88 | /// 89 | public var symbolColour: OSColor { 90 | didSet { id = UUID() } 91 | } 92 | 93 | /// The colour for type names (identifiers and operators). 94 | /// 95 | public var typeColour: OSColor { 96 | didSet { id = UUID() } 97 | } 98 | 99 | /// The colour for field names. 100 | /// 101 | public var fieldColour: OSColor { 102 | didSet { id = UUID() } 103 | } 104 | 105 | /// The colour for names of alternatives. 106 | /// 107 | public var caseColour: OSColor { 108 | didSet { id = UUID() } 109 | } 110 | 111 | /// The background colour. 112 | /// 113 | public var backgroundColour: OSColor { 114 | didSet { id = UUID() } 115 | } 116 | 117 | /// The colour of the current line highlight. 118 | /// 119 | public var currentLineColour: OSColor { 120 | didSet { id = UUID() } 121 | } 122 | 123 | /// The colour to use for the selection highlight. 124 | /// 125 | public var selectionColour: OSColor { 126 | didSet { id = UUID() } 127 | } 128 | 129 | /// The cursor colour. 130 | /// 131 | public var cursorColour: OSColor { 132 | didSet { id = UUID() } 133 | } 134 | 135 | /// The colour to use if invisibles are drawn. 136 | /// 137 | public var invisiblesColour: OSColor { 138 | didSet { id = UUID() } 139 | } 140 | 141 | public init(colourScheme: ColorScheme, 142 | fontName: String, 143 | fontSize: CGFloat, 144 | textColour: OSColor, 145 | commentColour: OSColor, 146 | stringColour: OSColor, 147 | characterColour: OSColor, 148 | numberColour: OSColor, 149 | identifierColour: OSColor, 150 | operatorColour: OSColor, 151 | keywordColour: OSColor, 152 | symbolColour: OSColor, 153 | typeColour: OSColor, 154 | fieldColour: OSColor, 155 | caseColour: OSColor, 156 | backgroundColour: OSColor, 157 | currentLineColour: OSColor, 158 | selectionColour: OSColor, 159 | cursorColour: OSColor, 160 | invisiblesColour: OSColor) 161 | { 162 | self.colourScheme = colourScheme 163 | self.fontName = fontName 164 | self.fontSize = fontSize 165 | self.textColour = textColour 166 | self.commentColour = commentColour 167 | self.stringColour = stringColour 168 | self.characterColour = characterColour 169 | self.numberColour = numberColour 170 | self.identifierColour = identifierColour 171 | self.operatorColour = operatorColour 172 | self.keywordColour = keywordColour 173 | self.symbolColour = symbolColour 174 | self.typeColour = typeColour 175 | self.fieldColour = fieldColour 176 | self.caseColour = caseColour 177 | self.backgroundColour = backgroundColour 178 | self.currentLineColour = currentLineColour 179 | self.selectionColour = selectionColour 180 | self.cursorColour = cursorColour 181 | self.invisiblesColour = invisiblesColour 182 | } 183 | } 184 | 185 | extension Theme: Equatable { 186 | 187 | public static func ==(lhs: Theme, rhs: Theme) -> Bool { lhs.id == rhs.id } 188 | } 189 | 190 | /// A theme catalog indexing themes by name 191 | /// 192 | typealias Themes = [String: Theme] 193 | 194 | extension Theme { 195 | 196 | public static var defaultDark: Theme 197 | = Theme(colourScheme: .dark, 198 | fontName: "SFMono-Medium", 199 | fontSize: 13.0, 200 | textColour: OSColor(red: 0.87, green: 0.87, blue: 0.88, alpha: 1.0), 201 | commentColour: OSColor(red: 0.51, green: 0.55, blue: 0.59, alpha: 1.0), 202 | stringColour: OSColor(red: 0.94, green: 0.53, blue: 0.46, alpha: 1.0), 203 | characterColour: OSColor(red: 0.84, green: 0.79, blue: 0.53, alpha: 1.0), 204 | numberColour: OSColor(red: 0.81, green: 0.74, blue: 0.40, alpha: 1.0), 205 | identifierColour: OSColor(red: 0.41, green: 0.72, blue: 0.64, alpha: 1.0), 206 | operatorColour: OSColor(red: 0.62, green: 0.94, blue: 0.87, alpha: 1.0), 207 | keywordColour: OSColor(red: 0.94, green: 0.51, blue: 0.69, alpha: 1.0), 208 | symbolColour: OSColor(red: 0.72, green: 0.72, blue: 0.73, alpha: 1.0), 209 | typeColour: OSColor(red: 0.36, green: 0.85, blue: 1.0, alpha: 1.0), 210 | fieldColour: OSColor(red: 0.63, green: 0.40, blue: 0.90, alpha: 1.0), 211 | caseColour: OSColor(red: 0.82, green: 0.66, blue: 1.0, alpha: 1.0), 212 | backgroundColour: OSColor(red: 0.16, green: 0.16, blue: 0.18, alpha: 1.0), 213 | currentLineColour: OSColor(red: 0.19, green: 0.20, blue: 0.22, alpha: 1.0), 214 | selectionColour: OSColor(red: 0.40, green: 0.44, blue: 0.51, alpha: 1.0), 215 | cursorColour: OSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), 216 | invisiblesColour: OSColor(red: 0.33, green: 0.37, blue: 0.42, alpha: 1.0)) 217 | 218 | public static var defaultLight: Theme 219 | = Theme(colourScheme: .light, 220 | fontName: "SFMono-Medium", 221 | fontSize: 13.0, 222 | textColour: OSColor(red: 0.15, green: 0.15, blue: 0.15, alpha: 1.0), 223 | commentColour: OSColor(red: 0.45, green: 0.50, blue: 0.55, alpha: 1.0), 224 | stringColour: OSColor(red: 0.76, green: 0.24, blue: 0.16, alpha: 1.0), 225 | characterColour: OSColor(red: 0.14, green: 0.19, blue: 0.81, alpha: 1.0), 226 | numberColour: OSColor(red: 0.0, green: 0.05, blue: 1.0, alpha: 1.0), 227 | identifierColour: OSColor(red: 0.23, green: 0.50, blue: 0.54, alpha: 1.0), 228 | operatorColour: OSColor(red: 0.18, green: 0.05, blue: 0.43, alpha: 1.0), 229 | keywordColour: OSColor(red: 0.63, green: 0.28, blue: 0.62, alpha: 1.0), 230 | symbolColour: OSColor(red: 0.24, green: 0.13, blue: 0.48, alpha: 1.0), 231 | typeColour: OSColor(red: 0.04, green: 0.29, blue: 0.46, alpha: 1.0), 232 | fieldColour: OSColor(red: 0.36, green: 0.15, blue: 0.60, alpha: 1.0), 233 | caseColour: OSColor(red: 0.18, green: 0.05, blue: 0.43, alpha: 1.0), 234 | backgroundColour: OSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), 235 | currentLineColour: OSColor(red: 0.93, green: 0.96, blue: 1.0, alpha: 1.0), 236 | selectionColour: OSColor(red: 0.73, green: 0.84, blue: 0.99, alpha: 1.0), 237 | cursorColour: OSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0), 238 | invisiblesColour: OSColor(red: 0.84, green: 0.84, blue: 0.84, alpha: 1.0)) 239 | } 240 | 241 | extension Theme { 242 | 243 | /// Font object on the basis of the font name and size of the theme. 244 | /// 245 | var font: OSFont { 246 | if fontName.hasPrefix("SFMono-") { 247 | 248 | let weightString = fontName.dropFirst("SFMono-".count) 249 | let weight : OSFont.Weight 250 | switch weightString { 251 | case "UltraLight": weight = .ultraLight 252 | case "Thin": weight = .thin 253 | case "Light": weight = .light 254 | case "Regular": weight = .regular 255 | case "Medium": weight = .medium 256 | case "Semibold": weight = .semibold 257 | case "Bold": weight = .bold 258 | case "Heavy": weight = .heavy 259 | case "Black": weight = .black 260 | default: weight = .regular 261 | } 262 | return OSFont.monospacedSystemFont(ofSize: fontSize, weight: weight) 263 | 264 | } else { 265 | 266 | return OSFont(name: fontName, size: fontSize) ?? OSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) 267 | 268 | } 269 | } 270 | 271 | #if os(iOS) || os(visionOS) 272 | 273 | /// Tint colour on the basis of the cursor and selection colour of the theme. 274 | /// 275 | var tintColour: UIColor { 276 | var selectionHue = CGFloat(0.0), 277 | selectionSaturation = CGFloat(0.0), 278 | selectionBrigthness = CGFloat(0.0), 279 | cursorHue = CGFloat(0.0), 280 | cursorSaturation = CGFloat(0.0), 281 | cursorBrigthness = CGFloat(0.0) 282 | 283 | // TODO: This is awkward... 284 | selectionColour.getHue(&selectionHue, 285 | saturation: &selectionSaturation, 286 | brightness: &selectionBrigthness, 287 | alpha: nil) 288 | cursorColour.getHue(&cursorHue, saturation: &cursorSaturation, brightness: &cursorBrigthness, alpha: nil) 289 | return UIColor(hue: selectionHue, 290 | saturation: 1.0, 291 | brightness: selectionBrigthness, 292 | alpha: 1.0) 293 | } 294 | 295 | #endif 296 | } 297 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/UIHostingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIHostingView.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 04/05/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | 11 | #if os(iOS) || os(visionOS) 12 | 13 | class UIHostingView: UIView { 14 | private let hostingViewController: UIHostingController 15 | 16 | var rootView: Content { 17 | get { hostingViewController.rootView } 18 | set { hostingViewController.rootView = newValue } 19 | } 20 | 21 | init(rootView:Content) { 22 | hostingViewController = UIHostingController(rootView: rootView) 23 | super.init(frame: .zero) 24 | 25 | hostingViewController.view?.translatesAutoresizingMaskIntoConstraints = false 26 | addSubview(hostingViewController.view) 27 | if let view = hostingViewController.view { 28 | 29 | view.backgroundColor = .clear 30 | view.isOpaque = false 31 | addSubview(view) 32 | let constraints = [ 33 | view.topAnchor.constraint(equalTo: self.topAnchor), 34 | view.bottomAnchor.constraint(equalTo: self.bottomAnchor), 35 | view.leftAnchor.constraint(equalTo: self.leftAnchor), 36 | view.rightAnchor.constraint(equalTo: self.rightAnchor) 37 | ] 38 | NSLayoutConstraint.activate(constraints) 39 | 40 | } 41 | } 42 | 43 | @available(*, unavailable) 44 | required init?(coder: NSCoder) { 45 | fatalError("init(coder:) has not been implemented") 46 | } 47 | 48 | override func sizeThatFits(_ size: CGSize) -> CGSize { 49 | hostingViewController.sizeThatFits(in: size) 50 | } 51 | } 52 | 53 | #endif 54 | -------------------------------------------------------------------------------- /Sources/CodeEditorView/ViewModifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModifers.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 27/03/2021. 6 | // 7 | // This file contains general purpose view modifiers. 8 | 9 | import SwiftUI 10 | 11 | 12 | // MARK: - 13 | // MARK: Views with rounded corners on the left hand side. 14 | 15 | fileprivate struct RectWithRoundedCornersOnTheLeft: Shape { 16 | let cornerRadius: CGFloat 17 | 18 | func path(in rect: CGRect) -> Path { 19 | var path = Path() 20 | 21 | let minXCorner = rect.minX + cornerRadius, 22 | minYCorner = rect.minY + cornerRadius, 23 | maxYCorner = rect.maxY - cornerRadius 24 | 25 | // We start in the top right corner and proceed clockwise 26 | path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) 27 | path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) 28 | path.addLine(to: CGPoint(x: minXCorner, y: rect.maxY)) 29 | 30 | path.addArc(center: CGPoint(x: minXCorner, y: maxYCorner), 31 | radius: cornerRadius, 32 | startAngle: Angle(degrees: 90), 33 | endAngle: Angle(degrees: 180), 34 | clockwise: false) 35 | 36 | path.addLine(to: CGPoint(x: rect.minX, y: minYCorner)) 37 | 38 | path.addArc(center: CGPoint(x: minXCorner, y: minYCorner), 39 | radius: cornerRadius, 40 | startAngle: Angle(degrees: 90), 41 | endAngle: Angle(degrees: 0), 42 | clockwise: false) 43 | 44 | path.addLine(to: CGPoint(x: minXCorner, y: rect.minY)) 45 | path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) 46 | return path 47 | } 48 | } 49 | 50 | extension View { 51 | 52 | /// Clip the view such that it has rounded corners on its left hand side. 53 | /// 54 | func roundedCornersOnTheLeft(cornerRadius: CGFloat = 5) -> some View { 55 | clipShape(RectWithRoundedCornersOnTheLeft(cornerRadius: cornerRadius)) 56 | } 57 | } 58 | 59 | 60 | #if os(iOS) 61 | 62 | // MARK: - 63 | // MARK: Dedecting device rotation 64 | 65 | fileprivate struct DeviceRotationViewModifier: ViewModifier { 66 | let action: (UIDeviceOrientation) -> Void 67 | 68 | func body(content: Content) -> some View { 69 | content 70 | .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in 71 | action(UIDevice.current.orientation) 72 | } 73 | } 74 | } 75 | 76 | extension View { 77 | 78 | /// Perform an action every time the device rotates. 79 | /// 80 | func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { 81 | self.modifier(DeviceRotationViewModifier(action: action)) 82 | } 83 | } 84 | 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/AgdaConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AgdaConfiguration.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 31/05/2023. 6 | // 7 | // Currently following Agda 2.6.4.4 8 | // 9 | 10 | import Foundation 11 | import RegexBuilder 12 | 13 | 14 | private let agdaReservedIds = 15 | ["abstract", "coinductive", "constructor", "data", "do", "eta-equality", "field", "forall", "hiding", "import", "in", 16 | "inductive", "infix", "infixl", "infixr", "instance", "interleaved", "let", "macro", "module", "mutual", 17 | "no-eta-equality", "opaque", "open", "overlap", "pattern", "postulate", "primitive", "private", "public", "quote", 18 | "quoteTerm", "record", "renaming", "rewrite", "syntax", "tactic", "unfolding", "unquote", "unquoteDecl", "unquoteDef", 19 | "using", "variable", "where", "with"] 20 | private let agdaReservedOperator = 21 | ["=", "|", "->", "→", ":", "?", "\\", "λ", "∀", "..", "..."] 22 | 23 | extension LanguageConfiguration { 24 | 25 | /// Language configuration for Agda 26 | /// 27 | public static func agda(_ languageService: LanguageService? = nil) -> LanguageConfiguration { 28 | let nameSeparator = CharacterClass(.anyOf(".;{}()@\""), .whitespace) 29 | let numberRegex = Regex { 30 | optNegation 31 | ChoiceOf { 32 | Regex{ /0[xX]/; hexalLit } 33 | Regex{ decimalLit; "."; decimalLit; Optionally{ exponentLit } } 34 | Regex{ decimalLit; exponentLit } 35 | decimalLit 36 | } 37 | } 38 | let agdaNamePartHeadChar: CharacterClass = .any.subtracting(.anyOf("_'")).subtracting(nameSeparator), 39 | agdaNamePartBodyChar: CharacterClass = CharacterClass(agdaNamePartHeadChar, .anyOf("'")) 40 | let namePart = Regex { agdaNamePartHeadChar; ZeroOrMore{ agdaNamePartBodyChar } }, 41 | identifierRegex = Regex { 42 | Optionally { /_/ } 43 | namePart 44 | ZeroOrMore { Regex { /_/; namePart } } 45 | Optionally { /_/ } 46 | } 47 | return LanguageConfiguration(name: "Agda", 48 | supportsSquareBrackets: false, 49 | supportsCurlyBrackets: true, 50 | indentationSensitiveScoping: true, 51 | stringRegex: /\"(?:\\\"|[^\"])*+\"/, 52 | characterRegex: /'(?:\\'|[^']|\\[^']*+)'/, 53 | numberRegex: numberRegex, 54 | singleLineComment: "--", 55 | nestedComment: (open: "{-", close: "-}"), 56 | identifierRegex: identifierRegex, 57 | operatorRegex: nil, 58 | reservedIdentifiers: agdaReservedIds, 59 | reservedOperators: agdaReservedOperator, 60 | languageService: languageService) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/CabalConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CabalConfiguration.swift 3 | // CodeEditorView 4 | // 5 | // Created by Manuel M T Chakravarty on 05/04/2025. 6 | // 7 | // Cabal configuration files: https://www.haskell.org/cabal/ 8 | 9 | import Foundation 10 | import RegexBuilder 11 | 12 | 13 | private let cabalReservedIds: [String] = [] 14 | private let cabalReservedOperators = [":"] 15 | 16 | extension LanguageConfiguration { 17 | 18 | /// Language configuration for Cabal configurations 19 | /// 20 | public static func cabal(_ languageService: LanguageService? = nil) -> LanguageConfiguration { 21 | let numberRegex = Regex { 22 | optNegation 23 | ChoiceOf { 24 | Regex{ decimalLit; ZeroOrMore { "."; decimalLit } } 25 | } 26 | } 27 | let identifierRegex = Regex { 28 | identifierHeadCharacters 29 | ZeroOrMore { 30 | CharacterClass(identifierCharacters, .anyOf("'-")) 31 | } 32 | /\:/ 33 | } 34 | let symbolCharacter = CharacterClass(.anyOf("&<=>|~")), 35 | operatorRegex = Regex { 36 | symbolCharacter 37 | ZeroOrMore { symbolCharacter } 38 | } 39 | return LanguageConfiguration(name: "Cabal", 40 | supportsSquareBrackets: false, 41 | supportsCurlyBrackets: false, 42 | indentationSensitiveScoping: true, 43 | stringRegex: /\"/, 44 | characterRegex: /'/, 45 | numberRegex: numberRegex, 46 | singleLineComment: "--", 47 | nestedComment: (open: "{-", close: "-}"), 48 | identifierRegex: identifierRegex, 49 | operatorRegex: operatorRegex, 50 | reservedIdentifiers: cabalReservedIds, 51 | reservedOperators: cabalReservedOperators, 52 | languageService: languageService) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/CypherConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CypherConfiguration.swift 3 | // 4 | // 5 | // Created by Carlo Rapisarda on 2024-12-21. 6 | // 7 | 8 | import Foundation 9 | import RegexBuilder 10 | 11 | extension LanguageConfiguration { 12 | 13 | public static func cypher(_ languageService: LanguageService? = nil) -> LanguageConfiguration { 14 | 15 | // Number Regex 16 | // Optional + or -, then digits, optional decimal, optional exponent 17 | let numberRegex: Regex = Regex { 18 | Optionally { 19 | ChoiceOf { 20 | "+" 21 | "-" 22 | } 23 | } 24 | OneOrMore(.digit) 25 | Optionally { 26 | "." 27 | OneOrMore(.digit) 28 | } 29 | Optionally { 30 | ChoiceOf { 31 | "e" 32 | "E" 33 | } 34 | Optionally { 35 | ChoiceOf { 36 | "+" 37 | "-" 38 | } 39 | } 40 | OneOrMore(.digit) 41 | } 42 | } 43 | 44 | // Identifier Regex 45 | // Plain identifiers: start with letter or underscore, then letters, digits, underscores 46 | let alphaOrUnderscore = ChoiceOf { 47 | "a"..."z" 48 | "A"..."Z" 49 | "_" 50 | } 51 | let alphaNumOrUnderscore = ChoiceOf { 52 | "a"..."z" 53 | "A"..."Z" 54 | "0"..."9" 55 | "_" 56 | } 57 | 58 | // Plain (unquoted) identifier 59 | let plainIdentifierRegex: Regex = Regex { 60 | alphaOrUnderscore 61 | ZeroOrMore { 62 | alphaNumOrUnderscore 63 | } 64 | } 65 | 66 | // Backtick-quoted identifier: everything except backticks 67 | let quotedIdentifierRegex: Regex = Regex { 68 | "`" 69 | ZeroOrMore { 70 | NegativeLookahead { 71 | "`" 72 | } 73 | // Match any character that is not a backtick 74 | CharacterClass.any 75 | } 76 | "`" 77 | } 78 | 79 | // Combine both unquoted and backtick-quoted 80 | let identifierRegex: Regex = Regex { 81 | ChoiceOf { 82 | plainIdentifierRegex 83 | quotedIdentifierRegex 84 | } 85 | } 86 | 87 | // String Regex (single or double quoted, with doubled quotes to escape) 88 | // Single-quoted strings 89 | let singleQuotedString: Regex = Regex { 90 | "'" 91 | ZeroOrMore { 92 | ChoiceOf { 93 | // Two single-quotes in a row => escaped quote 94 | Regex { 95 | "'" 96 | "'" 97 | } 98 | // Otherwise, any character except a single quote 99 | Regex { 100 | NegativeLookahead { "'" } 101 | CharacterClass.any 102 | } 103 | } 104 | } 105 | "'" 106 | } 107 | 108 | // Double-quoted strings 109 | let doubleQuotedString: Regex = Regex { 110 | "\"" 111 | ZeroOrMore { 112 | ChoiceOf { 113 | // Two double-quotes in a row => escaped quote 114 | Regex { 115 | "\"" 116 | "\"" 117 | } 118 | // Otherwise, any character except a double quote 119 | Regex { 120 | NegativeLookahead { "\"" } 121 | CharacterClass.any 122 | } 123 | } 124 | } 125 | "\"" 126 | } 127 | 128 | // Combine single-quoted or double-quoted into one string pattern 129 | let cypherStringRegex: Regex = Regex { 130 | ChoiceOf { 131 | singleQuotedString 132 | doubleQuotedString 133 | } 134 | } 135 | 136 | // Comment Syntax 137 | // Single-line: "//", nested: /* ... */ 138 | let singleLineComment = "//" 139 | let nestedComment = (open: "/*", close: "*/") 140 | 141 | // Keywords (case-insensitive) 142 | let cypherReservedIdentifiers = [ 143 | "all", "alter", "and", "as", "asc", "ascending", 144 | "by", "call", "case", "commit", "contains", "create", 145 | "delete", "desc", "descending", "detach", "distinct", "drop", 146 | "else", "end", "ends", "exists", "false", "fieldterminator", 147 | "filter", "in", "is", "limit", "load", "csv", "match", 148 | "merge", "not", "null", "on", "optional", "or", "order", 149 | "remove", "return", "skip", "start", "then", "true", "union", 150 | "unique", "unwind", "using", "when", "where", "with", "xor" 151 | ] 152 | 153 | // Operator Regex 154 | // We highlight multi-char and single-char operators. 155 | let operatorRegex: Regex = Regex { 156 | ChoiceOf { 157 | "<>" 158 | "<=" 159 | ">=" 160 | "=~" 161 | "=" 162 | "<" 163 | ">" 164 | "+" 165 | "-" 166 | "*" 167 | "/" 168 | "%" 169 | "^" 170 | "!" 171 | } 172 | } 173 | 174 | return LanguageConfiguration( 175 | name: "Cypher", 176 | supportsSquareBrackets: true, 177 | supportsCurlyBrackets: true, 178 | caseInsensitiveReservedIdentifiers: true, 179 | stringRegex: cypherStringRegex, 180 | characterRegex: nil, 181 | numberRegex: numberRegex, 182 | singleLineComment: singleLineComment, 183 | nestedComment: nestedComment, 184 | identifierRegex: identifierRegex, 185 | operatorRegex: operatorRegex, 186 | reservedIdentifiers: cypherReservedIdentifiers, 187 | reservedOperators: [], 188 | languageService: languageService 189 | ) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/HaskellConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HaskellConfiguration.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 10/01/2023. 6 | // 7 | 8 | import Foundation 9 | import RegexBuilder 10 | 11 | 12 | private let haskellReservedIds = 13 | ["case", "class", "data", "default", "deriving", "do", "else", "foreign", "if", "import", "in", "infix", "infixl", 14 | "infixr", "instance", "let", "module", "newtype", "of", "then", "type", "where"] 15 | private let haskellReservedOperators = 16 | ["..", ":", "::", "=", "\\", "|", "<-", "->", "@", "~", "=>"] 17 | 18 | extension LanguageConfiguration { 19 | 20 | /// Language configuration for Haskell (including GHC extensions) 21 | /// 22 | public static func haskell(_ languageService: LanguageService? = nil) -> LanguageConfiguration { 23 | let numberRegex = Regex { 24 | optNegation 25 | ChoiceOf { 26 | Regex{ /0[bB]/; binaryLit } 27 | Regex{ /0[oO]/; octalLit } 28 | Regex{ /0[xX]/; hexalLit } 29 | Regex{ /0[xX]/; hexalLit; "."; hexalLit; Optionally{ hexponentLit } } 30 | Regex{ decimalLit; "."; decimalLit; Optionally{ exponentLit } } 31 | Regex{ decimalLit; exponentLit } 32 | decimalLit 33 | } 34 | } 35 | let identifierRegex = Regex { 36 | identifierHeadCharacters 37 | ZeroOrMore { 38 | CharacterClass(identifierCharacters, .anyOf("'")) 39 | } 40 | } 41 | let symbolCharacter = CharacterClass(.anyOf("!#$%&⋆+./<=>?@\\^|-~:"), 42 | operatorHeadCharacters.subtracting(.anyOf("/=-+!*%<>&|^~?"))), 43 | // This is for the Unicode symbols, but the Haskell spec actually specifies "any Unicode symbol or punctuation". 44 | operatorRegex = Regex { 45 | symbolCharacter 46 | ZeroOrMore { symbolCharacter } 47 | } 48 | return LanguageConfiguration(name: "Haskell", 49 | supportsSquareBrackets: true, 50 | supportsCurlyBrackets: true, 51 | indentationSensitiveScoping: true, 52 | stringRegex: /\"(?:\\\"|[^\"])*+\"/, 53 | characterRegex: /'(?:\\'|[^']|\\[^']*+)'/, 54 | numberRegex: numberRegex, 55 | singleLineComment: "--", 56 | nestedComment: (open: "{-", close: "-}"), 57 | identifierRegex: identifierRegex, 58 | operatorRegex: operatorRegex, 59 | reservedIdentifiers: haskellReservedIds, 60 | reservedOperators: haskellReservedOperators, 61 | languageService: languageService) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/LanguageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LanguageService.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 10/01/2023. 6 | // 7 | // This file defines the interface for external services (such as an LSP server) to provide language-specific 8 | // syntactic and semantic information to the code editor for a single file. It uses `Combine` for the asynchronous 9 | // communication between information providers and the code editor, where necessary. 10 | // 11 | // An instance of a language service is specific to a single file. Hence, locations etc are always relative to the file 12 | // associated with the used language service. 13 | 14 | import SwiftUI 15 | import Combine 16 | 17 | 18 | /// Provider of document-specific location information for a language service. 19 | /// 20 | public protocol LocationService: LocationConverter { 21 | 22 | /// Yields the length of the given line. 23 | /// 24 | /// - Parameter line: The line of which we want to know the length, starting from 0. 25 | /// - Returns: The length (number of characters) of the given line, including any trainling newline character, or 26 | /// `nil` if the line does not exist. 27 | /// 28 | func length(of zeroBasedLine: Int) -> Int? 29 | } 30 | 31 | /// Indicates the reason for querying code completions. 32 | /// 33 | public enum CompletionTriggerReason { 34 | 35 | /// Completion was triggered by typing a character of an identifier or by explicitly requesting completion. 36 | /// 37 | case standard 38 | 39 | /// Completion was triggered by the given trigger character. 40 | /// 41 | case character(Character) 42 | 43 | /// Completion was re-triggered to refine an incomplete completion list (after an additional character has been 44 | /// provided). 45 | case incomplete 46 | } 47 | 48 | /// A set of code completions for a specific code position. 49 | /// 50 | public struct Completions { 51 | 52 | /// A single completion item. 53 | /// 54 | public struct Completion: Identifiable { 55 | 56 | /// Unique identifier in the current list of completions that remains stable during narrowing down and widening 57 | /// the list of completions. 58 | /// 59 | public let id: Int 60 | 61 | /// The view representing this completion in the completion selection list, after passing its selection status. 62 | /// 63 | public let rowView: (Bool) -> any View 64 | 65 | /// The view representing the documentation for this completion. 66 | /// 67 | public let documentationView: any View 68 | 69 | /// Whether this item ought to be selected in the list of possible completions. (Only one completion in a list of 70 | /// completions ought to have this flag set.) 71 | /// 72 | public let selected: Bool 73 | 74 | /// String to use when sorting completions. 75 | /// 76 | public let sortText: String 77 | 78 | /// String to use when filtering completions; e.g., upon further user input. 79 | /// 80 | public let filterText: String 81 | 82 | /// String to insert when this completion gets chosen. It may contain placeholders. 83 | /// 84 | public let insertText: String 85 | 86 | /// The range of the characters that are to be replaced by this completion if available. 87 | /// 88 | public let insertRange: NSRange? 89 | 90 | /// Characters that commit to this completion when typed while the completion is selected. 91 | /// 92 | public let commitCharacters: [Character] 93 | 94 | /// An optional callback that yields a refined version of the same completion item; i.e., a version that has a more 95 | /// informative row and/or documentation view. 96 | /// 97 | public let refine: (() async throws -> Completion?) 98 | 99 | public init(id: Int, 100 | rowView: @escaping (Bool) -> any View, 101 | documentationView: any View, 102 | selected: Bool, 103 | sortText: String, 104 | filterText: String, 105 | insertText: String, 106 | insertRange: NSRange?, 107 | commitCharacters: [Character], 108 | refine: @escaping (() async throws -> Completion?)) 109 | { 110 | self.id = id 111 | self.rowView = rowView 112 | self.documentationView = documentationView 113 | self.selected = selected 114 | self.sortText = sortText 115 | self.filterText = filterText 116 | self.insertText = insertText 117 | self.insertRange = insertRange 118 | self.commitCharacters = commitCharacters 119 | self.refine = refine 120 | } 121 | } 122 | 123 | /// Whether more code completions are possible at the code position in question. If so, the completions ought to be 124 | /// queried again once further user input is available. 125 | /// 126 | public let isIncomplete: Bool 127 | 128 | /// Suggested code completions at the code position in questions. 129 | /// 130 | public var items: [Completion] 131 | 132 | /// Yield a set of code completions for a specific code position. 133 | /// 134 | /// - Parameters: 135 | /// - isIncomplete: Whether more code completions are possible at the code position in question. 136 | /// - items: Suggested code completions. 137 | /// 138 | public init(isIncomplete: Bool, items: [Completions.Completion]) { 139 | self.isIncomplete = isIncomplete 140 | self.items = items 141 | } 142 | 143 | /// An empty set of completions. 144 | /// 145 | public static var none: Completions { Completions(isIncomplete: false, items: []) } 146 | } 147 | 148 | extension Completions.Completion: Comparable { 149 | 150 | public static func == (lhs: Completions.Completion, rhs: Completions.Completion) -> Bool { 151 | lhs.sortText == rhs.sortText 152 | } 153 | 154 | public static func < (lhs: Completions.Completion, rhs: Completions.Completion) -> Bool { 155 | lhs.sortText < rhs.sortText 156 | } 157 | } 158 | 159 | /// Extra action that is specific to a particular language (server). 160 | /// 161 | public struct ExtraAction { 162 | 163 | /// The name of the extra action, suitable for use in a menu. 164 | /// 165 | public let title: String 166 | 167 | /// An optional key equivalent that may be used to trigger the extra action when used with the modififiers determined 168 | /// by the hosting application. 169 | /// 170 | public let key: KeyEquivalent? 171 | 172 | /// The actual action. 173 | /// 174 | public var action: (() -> Void)? 175 | 176 | public init(title: String, key: KeyEquivalent? = nil, action: (() -> Void)? = nil) { 177 | self.title = title 178 | self.key = key 179 | self.action = action 180 | } 181 | } 182 | 183 | /// Events that can be reported by a language service. 184 | /// 185 | public enum LanguageServiceEvent { 186 | 187 | /// New semantic token information is available for the given line range. 188 | /// 189 | case tokensAvailable(lineRange: Range) 190 | } 191 | 192 | /// Determines the capabilities and endpoints for language-dependent external services, such as an LSP server. 193 | /// 194 | public protocol LanguageService: AnyObject { 195 | 196 | // MARK: Document synchronisation 197 | 198 | /// Determine whether the document is open. 199 | /// 200 | var isOpen: Bool { get } 201 | 202 | /// Notify the language service of the document that it controls. 203 | /// 204 | /// - Parameters: 205 | /// - text: The contents of the document. 206 | /// - locationService: A location service for the document. 207 | /// 208 | /// NB: This must be the first call to the language service. 209 | /// 210 | func openDocument(with text: String, locationService: LocationService) async throws 211 | 212 | /// Notify the language service of a document change. 213 | /// 214 | /// - Parameters: 215 | /// - changeLocation: The location at which the change originates. 216 | /// - delta: The change of the document length. 217 | /// - deltaLine: The change of the number of lines. 218 | /// - deltaColumn: The change of the column position on the last line of the changed text. 219 | /// - text: The text at `changeLocation` after the change. 220 | /// 221 | func documentDidChange(position changeLocation: Int, 222 | changeInLength delta: Int, 223 | lineChange deltaLine: Int, 224 | columnChange deltaColumn: Int, 225 | newText text: String) async throws 226 | 227 | /// Notify the language service that the document gets closed and the language service is no longer active. 228 | /// 229 | /// NB: After this call, no functions from the language service may be used anymore, except to open the document 230 | /// again. 231 | /// 232 | func closeDocument() async throws 233 | 234 | 235 | // MARK: Events 236 | 237 | /// Serves ephemeral events notifying a consumer of the language service of state changes that may require display 238 | /// refreshes, user-facing messages, and similar. 239 | /// 240 | var events: PassthroughSubject { get } 241 | 242 | 243 | // MARK: Diagnostics 244 | 245 | /// Notifies the code editor about a new set of diagnoistic messages. A new set replaces the previous set (merging 246 | /// happens server-side, not client-side). 247 | /// 248 | var diagnostics: CurrentValueSubject>, Never> { get } 249 | 250 | 251 | // MARK: Code completion 252 | 253 | /// Characters that are not valid inside identifiers, but should trigger code completion. 254 | /// 255 | var completionTriggerCharacters: CurrentValueSubject<[Character], Never> { get } 256 | 257 | /// Yield a set of completions at the given editing position 258 | /// 259 | /// - Parameters: 260 | /// - location: The text location for which completions are to be determined. 261 | /// - reason: The trigger that prompted this invocation. 262 | /// - Returns: A possible incomplete list of completions for the given `location`. 263 | /// 264 | func completions(at location: Int, reason: CompletionTriggerReason) async throws -> Completions 265 | 266 | 267 | // MARK: Semantic tokens 268 | 269 | /// Requests semantic token information for all tokens in the given line range. 270 | /// 271 | /// - Parameter lineRange: The lines whose semantic token information is being requested. The line count is zero-based. 272 | /// - Returns: Semantic tokens together with their line-relative character range divided up per line. The first 273 | /// subarray contains the semantic tokens for the first line of `lineRange` and so on. 274 | /// 275 | /// The number of elements of the result is the same as the number of lines in the `lineRange`. 276 | /// 277 | func tokens(for lineRange: Range) async throws -> [[(token: LanguageConfiguration.Token, range: NSRange)]] 278 | 279 | 280 | // MARK: Entity information 281 | 282 | /// Yields an info popover for the given location in the file associated with the current language service. 283 | /// 284 | /// - Parameter location: Index position in the associated textual representation of the code. 285 | /// - Returns: If semantic infotmation is available for the provided location, a view displaying that information is 286 | /// being returned. Optionally, the view may be accompanied by the character range to which the returned information 287 | /// pertains. 288 | /// 289 | /// In case there is an error, such as an invalid location, the function is expected to throw. However, if there is 290 | /// simply no extra information available for the given location, the function simply returns `nil`. 291 | /// 292 | func info(at location: Int) async throws -> (view: any View, anchor: NSRange?)? 293 | 294 | 295 | // MARK: Extra actions 296 | 297 | /// The current set of extra actions provided by the language service. These actions are dependent on the language and 298 | /// maybe also the particular language service. They can change in dependence on the state of the language service. 299 | /// 300 | var extraActions: CurrentValueSubject<[ExtraAction], Never> { get } 301 | 302 | 303 | // MARK: Developer support 304 | 305 | /// Render the capabilities of the underlying language service. 306 | /// 307 | /// - Returns: A view rendering the capabilities of the underlying language service. 308 | /// 309 | /// The information and its representation is dependent on the nature of the underlying language service and, in 310 | /// general, not fit for automatic interpretation. 311 | /// 312 | func capabilities() async throws -> (any View)? 313 | } 314 | 315 | extension LanguageService { 316 | 317 | /// Close the document and retract all diagnostics. 318 | /// 319 | public func stop() async throws { 320 | 321 | try await closeDocument() 322 | diagnostics.send(Set()) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/Location.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Location.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 09/05/2021. 6 | // 7 | // This file provides text positions and spans in a line-column format. 8 | 9 | import Foundation 10 | import System 11 | 12 | 13 | // MARK: - 14 | // MARK: Locations 15 | 16 | /// Location in a text in terms of a line-column position, with support to use the line and column counts starting from 17 | /// 0 or 1. The former is more convenient internally and the latter is better for user-facing information. 18 | /// 19 | public struct TextLocation { 20 | 21 | /// The line of the location, starting from 0. 22 | /// 23 | public let zeroBasedLine: Int 24 | 25 | /// The column on `zeroBasedLine` of the specified location, starting with column 0. 26 | /// 27 | public let zeroBasedColumn: Int 28 | 29 | /// Creates a text location from zero-based line and column values. 30 | /// 31 | /// - Parameters: 32 | /// - line: Zero-based line number 33 | /// - column: Zero-based column number 34 | /// 35 | public init(zeroBasedLine line: Int, column: Int) { 36 | self.zeroBasedLine = line 37 | self.zeroBasedColumn = column 38 | } 39 | 40 | /// Creates a text location from one-based line and column values. 41 | /// 42 | /// - Parameters: 43 | /// - line: One-based line number 44 | /// - column: One-based column number 45 | /// 46 | public init(oneBasedLine line: Int, column: Int) { 47 | self.zeroBasedLine = line - 1 48 | self.zeroBasedColumn = column - 1 49 | } 50 | 51 | /// The line of the location, starting from 1. 52 | /// 53 | public var oneBasedLine: Int { zeroBasedLine + 1 } 54 | 55 | /// The column on `oneBasedLine` of the specified location, starting with column 1. 56 | /// 57 | public var oneBasedColumn: Int { zeroBasedColumn + 1 } 58 | } 59 | 60 | /// Protocol for a service converting between index positions of a string and text locations in line-column format. 61 | /// 62 | public protocol LocationConverter { 63 | func textLocation(from location: Int) -> Result 64 | func location(from textLocation: TextLocation) -> Result 65 | } 66 | 67 | /// Generic text location attribute. 68 | /// 69 | public struct TextLocated { 70 | public let location: TextLocation 71 | public var entity: Entity 72 | 73 | /// Attribute the given entity with the given location. 74 | /// 75 | /// - Parameters: 76 | /// - location: The location to aasociate the `entity` with. 77 | /// - entity: The attributed entity. 78 | /// 79 | public init(location: TextLocation, entity: Entity) { 80 | self.location = location 81 | self.entity = entity 82 | } 83 | } 84 | 85 | extension TextLocated: Equatable where Entity: Equatable { 86 | static public func == (lhs: TextLocated, rhs: TextLocated) -> Bool { 87 | lhs.entity == rhs.entity 88 | } 89 | } 90 | 91 | extension TextLocated: Hashable where Entity: Hashable { 92 | public func hash(into hasher: inout Hasher) { hasher.combine(entity) } 93 | } 94 | 95 | /// Location in a named text file in terms of a line-column position, where the line and column count starts at 1. 96 | /// 97 | public struct FileLocation { 98 | 99 | 100 | /// The path of the text file containing the given text location. 101 | /// 102 | public let file: FilePath 103 | 104 | /// The location within the given `file`. 105 | /// 106 | public let location: TextLocation 107 | 108 | public var zeroBasedLine: Int { location.zeroBasedLine } 109 | public var zeroBasedColumn: Int { location.zeroBasedColumn } 110 | 111 | public var oneBasedLine: Int { location.oneBasedLine } 112 | public var oneBasedColumn: Int { location.oneBasedColumn } 113 | 114 | /// Construct a file location from a file path and a text location. 115 | /// 116 | /// - Parameters: 117 | /// - file: The file containing the location. 118 | /// - location: The location within the given file. 119 | /// 120 | public init(file: FilePath, location: TextLocation) { 121 | self.file = file 122 | self.location = location 123 | } 124 | 125 | /// Construct a file location from a file path and a line and column within that file. 126 | /// 127 | /// - Parameters: 128 | /// - file: The file containing the location. 129 | /// - line: The line within the given `file`, starting from 0. 130 | /// - column: The column (starting from zero) on the `line` within the given `file`. 131 | /// 132 | public init(file: FilePath, zeroBasedLine line: Int, column: Int) { 133 | self.init(file: file, location: TextLocation(zeroBasedLine: line, column: column)) 134 | } 135 | 136 | /// Construct a file location from a file path and a line and column within that file. 137 | /// 138 | /// - Parameters: 139 | /// - file: The file containing the location. 140 | /// - line: The line within the given `file`, starting from 1. 141 | /// - column: The column (starting from one) on the `line` within the given `file`. 142 | /// 143 | public init(file: FilePath, oneBasedLine line: Int, column: Int) { 144 | self.init(file: file, location: TextLocation(oneBasedLine: line, column: column)) 145 | } 146 | } 147 | 148 | /// Generic file location attribute. 149 | /// 150 | public struct FileLocated { 151 | public let location: FileLocation 152 | public let entity: Entity 153 | 154 | /// Attribute the given entity with the given location. 155 | /// 156 | /// - Parameters: 157 | /// - location: The location to aasociate the `entity` with. 158 | /// - entity: The attributed entity. 159 | /// 160 | public init(location: FileLocation, entity: Entity) { 161 | self.location = location 162 | self.entity = entity 163 | } 164 | } 165 | 166 | extension FileLocated: Equatable where Entity: Equatable { 167 | static public func == (lhs: FileLocated, rhs: FileLocated) -> Bool { 168 | lhs.entity == rhs.entity 169 | } 170 | } 171 | 172 | extension FileLocated: Hashable where Entity: Hashable { 173 | public func hash(into hasher: inout Hasher) { hasher.combine(entity) } 174 | } 175 | 176 | 177 | // MARK: - 178 | // MARK: Spans 179 | 180 | /// Character span in a text file. 181 | /// 182 | public struct Span { 183 | 184 | /// The location where the span starts. 185 | /// 186 | public let start: FileLocation 187 | 188 | /// The last line (starting from zero) containing at least one character of the span. This may be the same line as 189 | /// `start.location.line`. 190 | /// 191 | public let zeroBasedEndLine: Int 192 | 193 | /// The column (starting from zero) at which the last character of the span is located. 194 | /// 195 | public let zeroBasedEndColumn: Int 196 | 197 | /// Produce a span from a start location together with a zero-based end line and end column. 198 | /// 199 | /// - Parameters: 200 | /// - start: The location where the span starts. 201 | /// - endLine: The last line (starting from zero) containing at least one character of the span. 202 | /// - endColumn: The column (starting from zero) at which the last character of the span is located. 203 | /// 204 | public init(start: FileLocation, zeroBasedEndLine endLine: Int, endColumn: Int) { 205 | self.start = start 206 | self.zeroBasedEndLine = endLine 207 | self.zeroBasedEndColumn = endColumn 208 | } 209 | 210 | /// Produce a span from a start location together with a one-based end line and end column. 211 | /// 212 | /// - Parameters: 213 | /// - start: The location where the span starts. 214 | /// - endLine: The last line (starting from one) containing at least one character of the span. 215 | /// - endColumn: The column (starting from one) at which the last character of the span is located. 216 | /// 217 | public init(start: FileLocation, oneBasedEndLine endLine: Int, endColumn: Int) { 218 | self.start = start 219 | self.zeroBasedEndLine = endLine - 1 220 | self.zeroBasedEndColumn = endColumn - 1 221 | } 222 | 223 | /// The last line (starting from one) containing at least one character of the span. This may be the same line as 224 | /// `start.location.line`. 225 | /// 226 | public var oneBasedEndLine: Int { zeroBasedEndLine + 1 } 227 | 228 | /// The column (starting from one) at which the last character of the span is located. 229 | /// 230 | public var oneBasedCEndolumn: Int { zeroBasedEndColumn + 1 } 231 | } 232 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 22/03/2021. 6 | // 7 | // Messages are messages that can be displayed inline in a code view in short form and as a popup in long form. 8 | // They are bound to a particular primary location by way of line information, but may also include secondary locations 9 | // that contribute to the reported issue. A typical use case is diagnostic information. 10 | 11 | import Foundation 12 | 13 | 14 | /// A message that can be displayed in a code view. 15 | /// 16 | public struct Message { 17 | 18 | /// The various category that a message can be in. The earlier in the enumeration, the higher priority in the sense 19 | /// that in the one-line view, the colour of the highest priority message will be used. 20 | /// 21 | public enum Category: Equatable, Comparable, CaseIterable { 22 | 23 | /// A message related to live execution (e.g., debugger stepping position). 24 | /// 25 | case live 26 | 27 | /// An error — the program cannot be executed. 28 | /// 29 | case error 30 | 31 | /// A hole — indicating a gap in the code that still needs to be filled in. 32 | /// 33 | case hole 34 | 35 | /// A warning — indicating a possible problem that doesn't prevent execution. 36 | /// 37 | case warning 38 | 39 | /// A message without any direct impact on the validity of the program. 40 | /// 41 | case informational 42 | } 43 | 44 | /// Unique identity of the message. 45 | /// 46 | public let id: UUID = UUID() 47 | 48 | /// The message category 49 | /// 50 | public let category: Category 51 | 52 | /// The number of characters that the message is related to and which ought to be underlined. 53 | /// 54 | public let length: Int 55 | 56 | /// Short version of the message (displayed inline and in the popup) — one line only. 57 | /// 58 | public let summary: String 59 | 60 | /// Optional long message (only displayed in the popup, but may extend over multiple lines). 61 | /// 62 | public var description: AttributedString? 63 | 64 | /// The number of lines, beyond the line on which the message is located, that are to be highlighted (as they are 65 | /// within the scope of the message). 66 | /// 67 | public let telescope: Int? 68 | 69 | public init(category: Message.Category, 70 | length: Int, 71 | summary: String, 72 | description: AttributedString?, 73 | telescope: Int? = nil) 74 | { 75 | self.category = category 76 | self.length = length 77 | self.summary = summary 78 | self.description = description 79 | self.telescope = telescope 80 | } 81 | } 82 | 83 | extension Message: Equatable { 84 | public static func ==(lhs: Message, rhs: Message) -> Bool { lhs.id == rhs.id } 85 | } 86 | 87 | extension Message: Hashable { 88 | public func hash(into hasher: inout Hasher) { 89 | id.hash(into: &hasher) 90 | } 91 | } 92 | 93 | /// Order and sort an array of messages by categories. 94 | /// 95 | public func messagesByCategory(_ messages: [Message]) -> [(key: Message.Category, value: [Message])] { 96 | Array(Dictionary(grouping: messages){ $0.category }).sorted{ $0.key < $1.key } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/SQLiteConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SQLiteConfiguration.swift 3 | // 4 | // 5 | // Created by Ben Barnett on 07/11/2024. 6 | // 7 | 8 | import Foundation 9 | import RegexBuilder 10 | 11 | extension LanguageConfiguration { 12 | 13 | public static func sqlite(_ languageService: LanguageService? = nil) -> LanguageConfiguration { 14 | 15 | // https://www.sqlite.org/syntax/numeric-literal.html 16 | let numberRegex: Regex = Regex { 17 | Optionally("-") 18 | ChoiceOf { 19 | Regex { /0[xX]/; hexalLit } 20 | Regex { "."; decimalLit; Optionally { exponentLit } } 21 | Regex { decimalLit; "."; decimalLit; Optionally { exponentLit } } 22 | Regex { decimalLit; exponentLit } 23 | decimalLit 24 | } 25 | } 26 | let plainIdentifierRegex: Regex = Regex { 27 | sqliteIdentifierHeadCharacters 28 | ZeroOrMore { 29 | sqliteIdentifierCharacters 30 | } 31 | } 32 | let identifierRegex = Regex { 33 | ChoiceOf { 34 | plainIdentifierRegex 35 | Regex { "\""; plainIdentifierRegex; "\"" } 36 | Regex { "["; plainIdentifierRegex; "]" } 37 | Regex { "`"; plainIdentifierRegex; "`" } 38 | } 39 | } 40 | .matchingSemantics(.unicodeScalar) 41 | 42 | return LanguageConfiguration(name: "SQLite", 43 | supportsSquareBrackets: false, 44 | supportsCurlyBrackets: false, 45 | caseInsensitiveReservedIdentifiers: true, 46 | stringRegex: /'(?:''|[^'])*+'/, 47 | characterRegex: nil, 48 | numberRegex: numberRegex, 49 | singleLineComment: "--", 50 | nestedComment: (open: "/*", close: "*/"), 51 | identifierRegex: identifierRegex, 52 | operatorRegex: nil, 53 | reservedIdentifiers: sqliteReservedIdentifiers, 54 | reservedOperators: sqliteReservedOperators, 55 | languageService: languageService) 56 | } 57 | 58 | // See `sqlite3CtypeMap` in: 59 | // https://sqlite.org/src/file?name=ext/misc/normalize.c&ci=trunk 60 | private static let sqliteIdentifierHeadCharacters: CharacterClass = CharacterClass( 61 | "a"..."z", 62 | "A"..."Z", 63 | .anyOf("_"), 64 | "\u{80}" ... "\u{10FFFF}" 65 | ) 66 | 67 | private static let sqliteIdentifierCharacters: CharacterClass = CharacterClass( 68 | sqliteIdentifierHeadCharacters, 69 | "0"..."9", 70 | .anyOf("\u{24}") 71 | ) 72 | 73 | // https://sqlite.org/lang_keywords.html 74 | private static let sqliteReservedIdentifiers = [ 75 | "abort", 76 | "action", 77 | "add", 78 | "after", 79 | "all", 80 | "alter", 81 | "always", 82 | "analyze", 83 | "and", 84 | "as", 85 | "asc", 86 | "attach", 87 | "autoincrement", 88 | "before", 89 | "begin", 90 | "between", 91 | "by", 92 | "cascade", 93 | "case", 94 | "cast", 95 | "check", 96 | "collate", 97 | "column", 98 | "commit", 99 | "conflict", 100 | "constraint", 101 | "create", 102 | "cross", 103 | "current", 104 | "current_date", 105 | "current_time", 106 | "current_timestamp", 107 | "database", 108 | "default", 109 | "deferrable", 110 | "deferred", 111 | "delete", 112 | "desc", 113 | "detach", 114 | "distinct", 115 | "do", 116 | "drop", 117 | "each", 118 | "else", 119 | "end", 120 | "escape", 121 | "except", 122 | "exclude", 123 | "exclusive", 124 | "exists", 125 | "explain", 126 | "fail", 127 | "filter", 128 | "first", 129 | "following", 130 | "for", 131 | "foreign", 132 | "from", 133 | "full", 134 | "generated", 135 | "glob", 136 | "group", 137 | "groups", 138 | "having", 139 | "if", 140 | "ignore", 141 | "immediate", 142 | "in", 143 | "index", 144 | "indexed", 145 | "initially", 146 | "inner", 147 | "insert", 148 | "instead", 149 | "intersect", 150 | "into", 151 | "is", 152 | "isnull", 153 | "join", 154 | "key", 155 | "last", 156 | "left", 157 | "like", 158 | "limit", 159 | "match", 160 | "materialized", 161 | "natural", 162 | "no", 163 | "not", 164 | "nothing", 165 | "notnull", 166 | "null", 167 | "nulls", 168 | "of", 169 | "offset", 170 | "on", 171 | "or", 172 | "order", 173 | "others", 174 | "outer", 175 | "over", 176 | "partition", 177 | "plan", 178 | "pragma", 179 | "preceding", 180 | "primary", 181 | "query", 182 | "raise", 183 | "range", 184 | "recursive", 185 | "references", 186 | "regexp", 187 | "reindex", 188 | "release", 189 | "rename", 190 | "replace", 191 | "restrict", 192 | "returning", 193 | "right", 194 | "rollback", 195 | "row", 196 | "rows", 197 | "savepoint", 198 | "select", 199 | "set", 200 | "table", 201 | "temp", 202 | "temporary", 203 | "then", 204 | "ties", 205 | "to", 206 | "transaction", 207 | "trigger", 208 | "unbounded", 209 | "union", 210 | "unique", 211 | "update", 212 | "using", 213 | "vacuum", 214 | "values", 215 | "view", 216 | "virtual", 217 | "when", 218 | "where", 219 | "window", 220 | "with", 221 | "without", 222 | ] 223 | 224 | // https://www.sqlite.org/lang_expr.html#operators_and_parse_affecting_attributes 225 | private static let sqliteReservedOperators = [ 226 | "~", "+", "-", 227 | "||", "->", "->>", 228 | "*", "/", "%", 229 | "+", "-", 230 | "&", "|", "<<", ">>", 231 | "<", ">", "<=", ">=", 232 | "=", "==", "<>", "!=", 233 | ] 234 | 235 | 236 | } 237 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/SwiftConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftConfiguration.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 10/01/2023. 6 | // 7 | 8 | import Foundation 9 | import RegexBuilder 10 | 11 | 12 | private let swiftReservedIdentifiers = 13 | ["Any", "actor", "associatedtype", "async", "await", "as", "break", "case", "catch", "class", "continue", "default", 14 | "defer", "deinit", "do", "else", "enum", "extension", "fallthrough", "false", "fileprivate", "for", "func", "guard", 15 | "if", "in", "is", "import", "init", "inout", "internal", "in", "is", "let", "nil", "open", "operator", 16 | "precedencegroup", "private", "protocol", "public", "repeat", "rethrows", "return", "Self", "self", "static", 17 | "struct", "subscript", "super", "switch", "throw", "throws", "true", "try", "typealias", "var", "where", "while", 18 | "_", "#available", "#colorLiteral", "#else", "#elseif", "#endif", "#fileLiteral", "#if", "#imageLiteral", "#keyPath", 19 | "#selector", "#sourceLocation", "#unavailable"] 20 | private let swiftReservedOperators = 21 | [".", ",", ":", ";", "=", "@", "#", "&", "->", "`", "?", "!"] 22 | 23 | extension LanguageConfiguration { 24 | 25 | /// Language configuration for Swift 26 | /// 27 | public static func swift(_ languageService: LanguageService? = nil) -> LanguageConfiguration { 28 | let numberRegex: Regex = Regex { 29 | optNegation 30 | ChoiceOf { 31 | Regex { /0b/; binaryLit } 32 | Regex { /0o/; octalLit } 33 | Regex { /0x/; hexalLit } 34 | Regex { /0x/; hexalLit; "."; hexalLit; Optionally { hexponentLit } } 35 | Regex { decimalLit; "."; decimalLit; Optionally { exponentLit } } 36 | Regex { decimalLit; exponentLit } 37 | decimalLit 38 | } 39 | } 40 | let plainIdentifierRegex: Regex = Regex { 41 | identifierHeadCharacters 42 | ZeroOrMore { 43 | identifierCharacters 44 | } 45 | } 46 | let identifierRegex = Regex { 47 | ChoiceOf { 48 | plainIdentifierRegex 49 | Regex { "`"; plainIdentifierRegex; "`" } 50 | Regex { "$"; decimalLit } 51 | Regex { "$"; plainIdentifierRegex } 52 | } 53 | } 54 | let operatorRegex = Regex { 55 | ChoiceOf { 56 | 57 | Regex { 58 | operatorHeadCharacters 59 | ZeroOrMore { 60 | operatorCharacters 61 | } 62 | } 63 | 64 | Regex { 65 | "." 66 | OneOrMore { 67 | CharacterClass(operatorCharacters, .anyOf(".")) 68 | } 69 | } 70 | } 71 | } 72 | return LanguageConfiguration(name: "Swift", 73 | supportsSquareBrackets: true, 74 | supportsCurlyBrackets: true, 75 | stringRegex: /\"(?:\\\"|[^\"])*+\"/, 76 | characterRegex: nil, 77 | numberRegex: numberRegex, 78 | singleLineComment: "//", 79 | nestedComment: (open: "/*", close: "*/"), 80 | identifierRegex: identifierRegex, 81 | operatorRegex: operatorRegex, 82 | reservedIdentifiers: swiftReservedIdentifiers, 83 | reservedOperators: swiftReservedOperators, 84 | languageService: languageService) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/LanguageSupport/Tokeniser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tokeniser.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 03/11/2020. 6 | 7 | import os 8 | #if os(iOS) || os(visionOS) 9 | import UIKit 10 | #elseif os(macOS) 11 | import AppKit 12 | #endif 13 | 14 | import RegexBuilder 15 | 16 | import Rearrange 17 | 18 | 19 | private let logger = Logger(subsystem: "org.justtesting.CodeEditorView", category: "Tokeniser") 20 | 21 | 22 | // MARK: - 23 | // MARK: Regular expression-based tokenisers with explicit state management for context-free constructs 24 | 25 | /// Actions taken in response to matching a token 26 | /// 27 | /// The `token` component determines the token type of the matched pattern and `transition` determines the state 28 | /// transition implied by the matched token. If the `transition` component is `nil`, the tokeniser stays in the current 29 | /// state. 30 | /// 31 | public typealias TokenAction = (token: TokenType, transition: ((StateType) -> StateType)?) 32 | 33 | /// Token descriptions 34 | /// 35 | public struct TokenDescription { 36 | 37 | /// The regex to match the token. 38 | /// 39 | public let regex: Regex 40 | 41 | /// If the token has only got a single lexeme, it is specified here. 42 | /// 43 | /// When using a ``LanguageConfiguration`` which allows case-insensitive reserved identifiers, the contents will 44 | /// be converted to a lower-case representation for comparison. 45 | /// 46 | public let singleLexeme: String? 47 | 48 | /// The action to take when the token gets matched. 49 | /// 50 | public let action: TokenAction 51 | 52 | public init(regex: Regex, singleLexeme: String? = nil, action: TokenAction) { 53 | self.regex = regex 54 | self.singleLexeme = singleLexeme 55 | self.action = action 56 | } 57 | } 58 | 59 | public protocol TokeniserState { 60 | 61 | /// Finite projection of tokeniser state to determine sub-tokenisers (and hence, the regular expression to use) 62 | /// 63 | associatedtype StateTag: Hashable 64 | 65 | /// Project the tag out of a full state 66 | /// 67 | var tag: StateTag { get } 68 | } 69 | 70 | /// Type used to attribute characters with their token value. 71 | /// 72 | public struct TokenAttribute { 73 | 74 | /// `true` iff this is the first character of a tokens lexeme. 75 | /// 76 | public let isHead: Bool 77 | 78 | /// The type of tokens that this character is a part of. 79 | /// 80 | public let token: TokenType 81 | } 82 | 83 | /// For each possible state tag of the underlying tokeniser state, a mapping from token patterns to token kinds and 84 | /// maybe a state transition to determine a new tokeniser state. 85 | /// 86 | /// The matching of single lexeme tokens takes precedence over tokens with multiple lexemes. Within each category 87 | /// (single or multiple lexeme tokens), the order of the token description in the array indicates the order of matching 88 | /// preference; i.e., earlier elements take precedence. 89 | /// 90 | public typealias TokenDictionary 91 | = [StateType.StateTag: [TokenDescription]] 92 | 93 | /// Pre-compiled regular expression tokeniser. 94 | /// 95 | /// The `TokenType` identifies the various tokens that can be recognised by the tokeniser. 96 | /// 97 | public struct Tokeniser { 98 | 99 | /// The tokens produced by the tokensier. 100 | /// 101 | public struct Token: Equatable { 102 | 103 | /// The type of token matched. 104 | /// 105 | public let token: TokenType 106 | 107 | /// The range in the tokenised string where the token occurred. 108 | /// 109 | public var range: NSRange 110 | 111 | public init(token: TokenType, range: NSRange) { 112 | self.token = token 113 | self.range = range 114 | } 115 | 116 | /// Produce a copy with an adjusted location of the token by shifting it by the given amount. 117 | /// 118 | /// - Parameter amount: The amount by which to shift the token. (Positive amounts shift to the right and negative 119 | /// ones to the left.) 120 | /// 121 | public func shifted(by amount: Int) -> Token { 122 | return Token(token: token, range: NSRange(location: max(0, range.location + amount), length: range.length)) 123 | } 124 | } 125 | 126 | /// Tokeniser for one state of the compound tokeniser 127 | /// 128 | struct State { 129 | 130 | /// The regular expression used for matching in this state. 131 | /// 132 | /// The first capture in this regex is for the whole lot of single-lexeme tokens. The rest is for the multi-lexeme 133 | /// tokens, one each. 134 | /// 135 | let regex: Regex 136 | 137 | /// The lookup table for single-lexeme tokens. 138 | /// 139 | let stringTokenTypes: [String: TokenAction] 140 | 141 | /// The token types for multi-lexeme tokens. 142 | /// 143 | /// The order of the token types in the array is the same as that of the matching groups for those tokens in the 144 | /// regular expression. 145 | /// 146 | let patternTokenTypes: [TokenAction] 147 | } 148 | 149 | /// Sub-tokeniser for all states of the compound tokeniser. 150 | /// 151 | let states: [StateType.StateTag: State] 152 | 153 | /// Whether reserved identifiers are case-sensitive 154 | /// 155 | let caseInsensitiveReservedIdentifiers: Bool 156 | 157 | /// Create a tokeniser from the given token dictionary. 158 | /// 159 | /// - Parameters: 160 | /// - tokenMap: The token dictionary determining the lexemes to match and their token type. 161 | /// - caseInsensitiveReservedIdentifiers: Whether reserved identifiers should be matched case-insensitively 162 | /// - Returns: A tokeniser that matches all lexemes contained in the token dictionary. 163 | /// 164 | /// The tokeniser is based on an eager regular expression matcher. Hence, it will match the first matching alternative 165 | /// in a sequence of alternatives. To deal with string patterns, where some patterns may be a prefix of another, the 166 | /// string patterns are turned into regular expression alternatives longest string first. However, pattern consisting 167 | /// of regular expressions are tried in an indeterminate order. Hence, no pattern should have as a full match a prefix 168 | /// of another pattern's full match, to avoid indeterminate results. Moreover, strings match before patterns that 169 | /// cover the same lexeme. 170 | /// 171 | /// For each token that has got a multi-character lexeme, the tokeniser attributes the first character of that lexeme 172 | /// with a token attribute marked as being the lexeme head character. All other characters of the lexeme —what we call 173 | /// the token body— are marked with the same token attribute, but without being identified as a lexeme head. This 174 | /// distinction is crucial to be able to distinguish the boundaries of multiple successive tokens of the same type. 175 | /// 176 | public init?(for tokenMap: TokenDictionary, caseInsensitiveReservedIdentifiers: Bool = false) 177 | { 178 | func combine(alternatives: [TokenDescription]) -> Regex? { 179 | switch alternatives.count { 180 | case 0: return nil 181 | case 1: return alternatives[0].regex 182 | default: return alternatives[1...].reduce(alternatives[0].regex) { (regex, alternative) in 183 | Regex { ChoiceOf { regex; alternative.regex } } 184 | } 185 | } 186 | } 187 | 188 | func combineWithCapture(alternatives: [TokenDescription]) -> Regex? { 189 | switch alternatives.count { 190 | case 0: return nil 191 | case 1: return Regex(Regex { Capture { alternatives[0].regex } }) 192 | default: return alternatives[1...].reduce(Regex(Regex { Capture { alternatives[0].regex } })) { (regex, alternative) in 193 | Regex(Regex { ChoiceOf { regex; Capture { alternative.regex } } }) 194 | } 195 | } 196 | } 197 | 198 | func longestFirst(lhs: TokenDescription, rhs: TokenDescription) -> Bool 199 | { 200 | (lhs.singleLexeme ?? "").count >= (rhs.singleLexeme ?? "").count 201 | } 202 | 203 | func tokeniser(for stateMap: [TokenDescription]) -> Tokeniser.State? 204 | { 205 | 206 | // NB: The list of single lexeme tokens need to be from longest to shortest, to ensure that the longer one is 207 | // chosen if the lexeme of one token is a prefix of another token's lexeme. 208 | let singleLexemeTokens = stateMap.filter{ $0.singleLexeme != nil }.sorted(by: longestFirst), 209 | multiLexemeTokens = stateMap.filter{ $0.singleLexeme == nil }, 210 | singleLexemeTokensRegex = combine(alternatives: singleLexemeTokens), 211 | multiLexemeTokensRegex = combineWithCapture(alternatives: multiLexemeTokens) 212 | 213 | let stringTokenTypes: [(String, TokenAction)] = singleLexemeTokens.compactMap { token in 214 | guard let lexeme = token.singleLexeme else { return nil } 215 | let lexemeToUse = caseInsensitiveReservedIdentifiers ? lexeme.lowercased() : lexeme 216 | return (lexemeToUse, token.action) 217 | } 218 | let patternTokenTypes = multiLexemeTokens.map{ $0.action } 219 | 220 | let regex: Regex? = switch (singleLexemeTokensRegex, multiLexemeTokensRegex) { 221 | case (nil, nil): 222 | nil 223 | case (.some(let single), nil): 224 | Regex(Regex { Capture { single } }) 225 | case (nil, .some(let multi)): 226 | multi 227 | case (.some(let single), .some(let multi)): 228 | Regex(Regex { ChoiceOf { 229 | Capture { single } 230 | multi 231 | }}) 232 | } 233 | return if let regex { 234 | 235 | Tokeniser.State(regex: regex, 236 | stringTokenTypes: [String: TokenAction](stringTokenTypes){ 237 | (left, right) in return left }, 238 | patternTokenTypes: patternTokenTypes) 239 | 240 | } else { nil } 241 | } 242 | 243 | states = tokenMap.compactMapValues{ tokeniser(for: $0) } 244 | self.caseInsensitiveReservedIdentifiers = caseInsensitiveReservedIdentifiers 245 | if states.isEmpty { logger.debug("failed to compile regexp"); return nil } 246 | } 247 | } 248 | 249 | extension StringProtocol { 250 | 251 | /// Tokenise `self` and return the encountered tokens. 252 | /// 253 | /// - Parameters: 254 | /// - tokeniser: Pre-compiled tokeniser. 255 | /// - startState: Starting state of the tokeniser. 256 | /// - Returns: The sequence of the encountered tokens. 257 | /// 258 | /// NB: If this function is applied to a `Substring`, the ranges of the tokens are relative to the start of the 259 | /// `Substring` (and not of the `String` of which this `Substring` is a part). 260 | /// 261 | public func tokenise(with tokeniser: Tokeniser, 262 | state startState: StateType) 263 | -> [Tokeniser.Token] 264 | { 265 | var state = startState 266 | var currentStart = startIndex 267 | var tokens = [] as [Tokeniser.Token] 268 | 269 | // Tokenise and set appropriate attributes 270 | while currentStart < endIndex { 271 | 272 | guard let stateTokeniser = tokeniser.states[state.tag], 273 | let currentSubstring = self[currentStart...] as? Substring, 274 | let result = try? stateTokeniser.regex.firstMatch(in: currentSubstring) 275 | else { break } // no more match => stop 276 | 277 | // We are going to look for the next lexeme from just after the one we just found 278 | currentStart = result.range.upperBound 279 | 280 | var tokenAction: TokenAction? 281 | if result[1].range != nil { // that is the capture for the whole lot of single lexeme tokens 282 | 283 | let lookupString = if tokeniser.caseInsensitiveReservedIdentifiers { 284 | String(self[result.range]).lowercased() 285 | } else { 286 | String(self[result.range]) 287 | } 288 | tokenAction = stateTokeniser.stringTokenTypes[lookupString] 289 | 290 | } else { 291 | 292 | // If a matching group in the regexp matched, select the action of the corresponding pattern. 293 | for i in 2.. complex pattern match 296 | 297 | tokenAction = stateTokeniser.patternTokenTypes[i - 2] 298 | break 299 | } 300 | } 301 | } 302 | 303 | if let action = tokenAction, !result.range.isEmpty { 304 | 305 | tokens.append(.init(token: action.token, range: NSRange(result.range, in: self))) 306 | 307 | // If there is an associated state transition function, apply it to the tokeniser state 308 | if let transition = action.transition { state = transition(state) } 309 | 310 | } 311 | } 312 | return tokens 313 | } 314 | } 315 | 316 | -------------------------------------------------------------------------------- /Tests/CodeEditorTests/CodeEditorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CodeEditorView 3 | 4 | final class CodeEditorTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | XCTAssertEqual("Hello," + " World!", "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Tests/CodeEditorTests/CypherConfigurationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CypherConfigurationTests.swift 3 | // 4 | // 5 | // Created by Carlo Rapisarda on 2024-12-22. 6 | // 7 | 8 | import Foundation 9 | import RegexBuilder 10 | import Testing 11 | @testable import CodeEditorView 12 | @testable import LanguageSupport 13 | 14 | struct CypherNumberTokenisingTests { 15 | 16 | let codeStorage: CodeStorage 17 | let codeStorageDelegate: CodeStorageDelegate 18 | 19 | init() { 20 | codeStorageDelegate = CodeStorageDelegate(with: .cypher(), setText: { _ in }) 21 | codeStorage = CodeStorage(theme: .defaultLight) 22 | codeStorage.delegate = codeStorageDelegate 23 | } 24 | 25 | @Test("Recognises valid numbers", arguments: validNumbers) 26 | func tokenisesAsNumber(number: String) throws { 27 | let attributedString = NSAttributedString(string: number) 28 | codeStorage.setAttributedString(attributedString) // triggers tokenisation 29 | let lineMap = codeStorageDelegate.lineMap 30 | 31 | #expect(lineMap.lines.count == 1) 32 | 33 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "A token should be found") 34 | #expect(tokens.count == 1, "A single token should be found") 35 | 36 | let expectedToken = Tokeniser.Token( 37 | token: .number, 38 | range: NSRange(location: 0, length: attributedString.length) 39 | ) 40 | #expect(tokens.first == expectedToken) 41 | } 42 | 43 | /// A few examples of numeric literals that should be valid in Cypher: 44 | private static var validNumbers: [String] = [ 45 | "42", 46 | "-42", 47 | "3.14159", 48 | "0.5", 49 | "-0.5", 50 | "123.456e2", 51 | "-2.5E-3", 52 | "1E+6" 53 | ] 54 | } 55 | 56 | struct CypherIdentifierTokenisingTests { 57 | 58 | let codeStorage: CodeStorage 59 | let codeStorageDelegate: CodeStorageDelegate 60 | 61 | init() { 62 | codeStorageDelegate = CodeStorageDelegate(with: .cypher(), setText: { _ in }) 63 | codeStorage = CodeStorage(theme: .defaultLight) 64 | codeStorage.delegate = codeStorageDelegate 65 | } 66 | 67 | @Test("Recognises valid identifiers", arguments: validIdentifiers) 68 | func tokenisesAsIdentifier(text: String) throws { 69 | let attributedStringValue = NSAttributedString(string: text) 70 | codeStorage.setAttributedString(attributedStringValue) // triggers tokenisation 71 | let lineMap = codeStorageDelegate.lineMap 72 | 73 | #expect(lineMap.lines.count == 1) 74 | 75 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "A token should be found") 76 | #expect(tokens.count == 1, "A single token should be found") 77 | 78 | let expectedToken = Tokeniser.Token( 79 | token: .identifier(nil), 80 | range: NSRange(location: 0, length: attributedStringValue.length) 81 | ) 82 | #expect(tokens.first == expectedToken) 83 | } 84 | 85 | @Test("Recognises invalid identifiers", arguments: invalidIdentifiers) 86 | func tokenisesAsNonIdentifier(text: String) throws { 87 | let attributedStringValue = NSAttributedString(string: text) 88 | codeStorage.setAttributedString(attributedStringValue) // triggers tokenisation 89 | let lineMap = codeStorageDelegate.lineMap 90 | 91 | #expect(lineMap.lines.count == 1) 92 | 93 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "Tokens should be found") 94 | #expect(tokens.count > 1, "Multiple tokens should be found if it's not a single valid identifier.") 95 | } 96 | 97 | /// Valid Cypher identifiers: 98 | /// - Unquoted: start with letter or underscore, followed by letters, digits, underscores 99 | /// - Backtick-quoted: can contain more varied characters 100 | private static var validIdentifiers: [String] = [ 101 | "n", 102 | "_node", 103 | "abc123", 104 | "my_label", 105 | "`some weird stuff`", 106 | "`Person:Label`", 107 | "`some backtick: literal with !@#$%^&*()`", 108 | ] 109 | 110 | /// Some invalid identifier examples (leading digit, invalid symbol, etc.) 111 | private static var invalidIdentifiers: [String] = [ 112 | "1abc", // leading digit 113 | "node!", // exclamation mark outside backticks 114 | "my-node" // hyphen not allowed in unquoted 115 | ] 116 | } 117 | 118 | struct CypherStringTokenisingTests { 119 | 120 | let codeStorage: CodeStorage 121 | let codeStorageDelegate: CodeStorageDelegate 122 | 123 | init() { 124 | codeStorageDelegate = CodeStorageDelegate(with: .cypher(), setText: { _ in }) 125 | codeStorage = CodeStorage(theme: .defaultLight) 126 | codeStorage.delegate = codeStorageDelegate 127 | } 128 | 129 | @Test("Recognises valid strings", arguments: validStrings) 130 | func tokenisesAsString(text: String) throws { 131 | let attributedStringValue = NSAttributedString(string: text) 132 | codeStorage.setAttributedString(attributedStringValue) // triggers tokenisation 133 | let lineMap = codeStorageDelegate.lineMap 134 | 135 | #expect(lineMap.lines.count == 1) 136 | 137 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "A token should be found") 138 | #expect(tokens.count == 1, "A single token should be found") 139 | 140 | let expectedToken = Tokeniser.Token( 141 | token: .string, 142 | range: NSRange(location: 0, length: attributedStringValue.length) 143 | ) 144 | #expect(tokens.first == expectedToken) 145 | } 146 | 147 | /// Cypher strings can be single-quoted or double-quoted, with doubling of quotes to escape: 148 | private static var validStrings: [String] = [ 149 | "''", // empty string in single quotes 150 | "'Hello World'", 151 | "'It''s a string'", // embedded single quote 152 | "\"\"", 153 | "\"Hello World\"", 154 | "\"Double \"\"quote\"\" example\"", 155 | "'A \"nested\" example with single quotes'", 156 | "\"A 'nested' example with double quotes\"", 157 | ] 158 | } 159 | -------------------------------------------------------------------------------- /Tests/CodeEditorTests/LineMapTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import CodeEditorView 3 | 4 | func mkRange(loc: Int, len: Int) -> LineMap.OneLine { 5 | return (range: NSRange(location: loc, length: len), info: nil) 6 | } 7 | 8 | final class LineMapTests: XCTestCase { 9 | 10 | // Initialisation tests 11 | 12 | func hasLineMap(_ string: String, _ lineMap: LineMap) { 13 | let computedLineMap = LineMap(string: string) 14 | XCTAssertEqual(computedLineMap.lines.map{ $0.range }, lineMap.lines.map{ $0.range }) 15 | } 16 | 17 | func testInitEmpty() { 18 | hasLineMap("", 19 | LineMap(lines: [mkRange(loc: 0, len: 0)])) 20 | } 21 | 22 | func testInitLineBreak() { 23 | hasLineMap("\n", 24 | LineMap(lines: [mkRange(loc: 0, len: 1), mkRange(loc: 1, len: 0)])) 25 | } 26 | 27 | func testInitSimple() { 28 | hasLineMap("abc", 29 | LineMap(lines: [mkRange(loc: 0, len: 3)])) 30 | } 31 | 32 | func testInitSimpleTrailing() { 33 | hasLineMap("abc\n", 34 | LineMap(lines: [mkRange(loc: 0, len: 4), mkRange(loc: 4, len: 0)])) 35 | } 36 | 37 | func testInitLines() { 38 | hasLineMap("abc\ndefg\nhij", 39 | LineMap(lines: [mkRange(loc: 0, len: 4), 40 | mkRange(loc: 4, len: 5), 41 | mkRange(loc: 9, len: 3)])) 42 | } 43 | 44 | func testInitEmptyLines() { 45 | hasLineMap("\nabc\n\n\ndefg\nhi\n", 46 | LineMap(lines: [mkRange(loc: 0, len: 1), 47 | mkRange(loc: 1, len: 4), 48 | mkRange(loc: 5, len: 1), 49 | mkRange(loc: 6, len: 1), 50 | mkRange(loc: 7, len: 5), 51 | mkRange(loc: 12, len: 3), 52 | mkRange(loc: 15, len: 0)])) 53 | } 54 | 55 | 56 | // Lookup tests 57 | 58 | func testLookupEmpty() { 59 | XCTAssertNil(LineMap(string: "").lineContaining(index: 0)) 60 | } 61 | 62 | func testLookupLineBreak() { 63 | let lineMap = LineMap(string: "\n") 64 | XCTAssertNil(lineMap.lineContaining(index: 1)) 65 | XCTAssertEqual(lineMap.lineContaining(index: 0), 0) 66 | } 67 | 68 | func testLookupSimple() { 69 | let lineMap = LineMap(string: "abc") 70 | XCTAssertNil(lineMap.lineContaining(index: 3)) 71 | XCTAssertEqual(lineMap.lineContaining(index: 0), 0) 72 | XCTAssertEqual(lineMap.lineContaining(index: 2), 0) 73 | } 74 | 75 | func testLookupSimpleTrailing() { 76 | let lineMap = LineMap(string: "abc\n") 77 | XCTAssertNil(lineMap.lineContaining(index: 4)) 78 | XCTAssertEqual(lineMap.lineContaining(index: 0), 0) 79 | XCTAssertEqual(lineMap.lineContaining(index: 2), 0) 80 | XCTAssertEqual(lineMap.lineContaining(index: 3), 0) 81 | } 82 | 83 | func testLookupLines() { 84 | let lineMap = LineMap(string: "abc\ndefg\nhij") 85 | XCTAssertEqual(lineMap.lineContaining(index: 0), 0) 86 | XCTAssertEqual(lineMap.lineContaining(index: 3), 0) 87 | XCTAssertEqual(lineMap.lineContaining(index: 4), 1) 88 | XCTAssertEqual(lineMap.lineContaining(index: 9), 2) 89 | } 90 | 91 | func testLookupEmptyLines() { 92 | let lineMap = LineMap(string: "\nabc\n\n\ndefg\nhi\n") 93 | XCTAssertEqual(lineMap.lineContaining(index: 0), 0) 94 | XCTAssertEqual(lineMap.lineContaining(index: 4), 1) 95 | XCTAssertEqual(lineMap.lineContaining(index: 5), 2) 96 | XCTAssertEqual(lineMap.lineContaining(index: 11), 4) 97 | XCTAssertEqual(lineMap.lineContaining(index: 14), 5) 98 | } 99 | 100 | // Editing tests 101 | 102 | func testEditing(string: String, into newString: String, range: NSRange, changeInLength: Int) { 103 | var lineMap = LineMap(string: string) 104 | lineMap.updateAfterEditing(string: newString, range: range, changeInLength: changeInLength) 105 | hasLineMap(newString, lineMap) 106 | } 107 | 108 | func testEditingEmpty() { 109 | let string = "" 110 | testEditing(string: string, into: "abc", range: NSRange(location: 0, length: 3), changeInLength: 3) 111 | testEditing(string: string, into: "abc\n", range: NSRange(location: 0, length: 4), changeInLength: 4) 112 | } 113 | 114 | func testEditingLineBreak() { 115 | let string = "\n" 116 | testEditing(string: string, into: "abc", range: NSRange(location: 0, length: 3), changeInLength: 2) 117 | testEditing(string: string, into: "abc\n", range: NSRange(location: 0, length: 3), changeInLength: 3) 118 | testEditing(string: string, into: "\nabc", range: NSRange(location: 1, length: 3), changeInLength: 3) 119 | testEditing(string: string, into: "\n\n", range: NSRange(location: 0, length: 1), changeInLength: 1) 120 | 121 | testEditing(string: string, into: "x", range: NSRange(location: 0, length: 1), changeInLength: 0) 122 | 123 | testEditing(string: string, into: "", range: NSRange(location: 0, length: 0), changeInLength: -1) 124 | } 125 | 126 | func testEditingSimple() { 127 | let string = "abc" 128 | testEditing(string: string, into: "abc", range: NSRange(location: 1, length: 0), changeInLength: 0) 129 | testEditing(string: string, into: "abc\n", range: NSRange(location: 3, length: 1), changeInLength: 1) 130 | testEditing(string: string, into: "\nabc", range: NSRange(location: 0, length: 1), changeInLength: 1) 131 | testEditing(string: string, into: "ab\nc", range: NSRange(location: 2, length: 1), changeInLength: 1) 132 | testEditing(string: string, into: "ab\n\n\nc", range: NSRange(location: 2, length: 3), changeInLength: 3) 133 | 134 | testEditing(string: string, into: "ac", range: NSRange(location: 1, length: 0), changeInLength: -1) 135 | } 136 | 137 | func testEditingSimpleTrailing() { 138 | let string = "abc\n" 139 | testEditing(string: string, into: "abc\n\n\n", range: NSRange(location: 4, length: 2), changeInLength: 2) 140 | testEditing(string: string, into: "abc\n\n", range: NSRange(location: 3, length: 1), changeInLength: 1) 141 | testEditing(string: string, into: "ab\n\n\nc\n", range: NSRange(location: 2, length: 3), changeInLength: 3) 142 | 143 | testEditing(string: string, into: "abcx", range: NSRange(location: 3, length: 1), changeInLength: 0) 144 | 145 | testEditing(string: string, into: "abc", range: NSRange(location: 3, length: 0), changeInLength: -1) 146 | } 147 | 148 | func testEditingEmptyLines() { 149 | let string = "\nabc\n\n\ndefg\nhi\n" 150 | testEditing(string: string, into: "x\nabc\n\n\ndefg\nhi\n", range: NSRange(location: 0, length: 1), changeInLength: 1) 151 | testEditing(string: string, into: "\nxabc\n\n\ndefg\nhi\n", range: NSRange(location: 1, length: 1), changeInLength: 1) 152 | testEditing(string: string, into: "\nabcx\n\n\ndefg\nhi\n", range: NSRange(location: 4, length: 1), changeInLength: 1) 153 | testEditing(string: string, into: "\nabc\nx\n\ndefg\nhi\n", range: NSRange(location: 5, length: 1), changeInLength: 1) 154 | 155 | testEditing(string: string, into: "\nabc\n\n\ndefg\nhix", range: NSRange(location: 14, length: 1), changeInLength: 0) 156 | testEditing(string: string, into: "\nabc\nx\ndefg\nhi\n", range: NSRange(location: 5, length: 1), changeInLength: 0) 157 | 158 | testEditing(string: string, into: "\nabc\n\ndefg\nhi\n", range: NSRange(location: 5, length: 0), changeInLength: -1) 159 | testEditing(string: string, into: "\nabc\n\ndefg\nhi\n", range: NSRange(location: 6, length: 0), changeInLength: -1) 160 | testEditing(string: string, into: "\nabcdefg\nhi\n", range: NSRange(location: 4, length: 0), changeInLength: -3) 161 | testEditing(string: string, into: "abc\n\n\ndefg\nhi\n", range: NSRange(location: 0, length: 0), changeInLength: -1) 162 | } 163 | 164 | // Doesn't seem useful as we cannot easily harden against all such inconsistent invocations. 165 | // func testEditingBogus() { 166 | // let string = "\nabc\n\n\ndefg\nhi\n" 167 | // 168 | // testEditing(string: string, into: "\nabc\nx\ndefg\nhi\n", range: NSRange(location: 5, length: 1), changeInLength: -100) 169 | // testEditing(string: string, into: "\nabc\nx\ndefg\nhi\n", range: NSRange(location: 5, length: 1), changeInLength: 10) 170 | // testEditing(string: string, into: "\nabc\nx\ndefg\nhi\n", range: NSRange(location: 5, length: 20), changeInLength: 10) 171 | // } 172 | 173 | static var allTests = [ 174 | ("testInitEmpty", testInitEmpty), 175 | ("testInitLineBreak", testInitLineBreak), 176 | ("testInitSimple", testInitSimple), 177 | ("testInitEmptyTrailing", testInitSimpleTrailing), 178 | ("testInitLines", testInitLines), 179 | ("testInitEmptyLines", testInitEmptyLines), 180 | 181 | ("testLookupEmpty", testLookupEmpty), 182 | ("testLookupLineBreak", testLookupLineBreak), 183 | ("testLookupSimple", testLookupSimple), 184 | ("testLookupEmptyTrailing", testLookupSimpleTrailing), 185 | ("testLookupLines", testLookupLines), 186 | ("testLookupEmptyLines", testLookupEmptyLines), 187 | 188 | ("testEditingEmpty", testEditingEmpty), 189 | ("testEditingLineBreak", testEditingLineBreak), 190 | ("testEditingSimple", testEditingSimple), 191 | ("testEditingSimpleTrailing", testEditingSimpleTrailing), 192 | ("testEditingEmptyLines", testEditingEmptyLines), 193 | 194 | // ("testEditingBogus", testEditingBogus), 195 | ] 196 | } 197 | -------------------------------------------------------------------------------- /Tests/CodeEditorTests/SQLiteConfigurationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SQLiteConfigurationTests.swift 3 | // CodeEditorTests 4 | // 5 | // Created by Ben Barnett on 09/11/2024. 6 | // 7 | 8 | import Foundation 9 | import RegexBuilder 10 | import Testing 11 | @testable import CodeEditorView 12 | @testable import LanguageSupport 13 | 14 | 15 | struct SQLiteNumberTokenisingTests { 16 | 17 | let codeStorage: CodeStorage 18 | let codeStorageDelegate: CodeStorageDelegate 19 | 20 | init() { 21 | codeStorageDelegate = CodeStorageDelegate(with: .sqlite(), setText: { _ in }) 22 | codeStorage = CodeStorage(theme: .defaultLight) 23 | codeStorage.delegate = codeStorageDelegate 24 | } 25 | 26 | @Test("Recognises valid numbers", arguments: validNumbers) 27 | func tokenisesAsNumber(number: String) throws { 28 | 29 | let attributedStringNumber = NSAttributedString(string: number) 30 | codeStorage.setAttributedString(attributedStringNumber) // this triggers tokenisation 31 | let lineMap = codeStorageDelegate.lineMap 32 | #expect(lineMap.lines.count == 1) 33 | 34 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "A token should be found") 35 | #expect(tokens.count == 1, "A single token should be found") 36 | 37 | let expectedToken = Tokeniser.Token( 38 | token: .number, 39 | range: NSRange(location: 0, length: attributedStringNumber.length) 40 | ) 41 | #expect(tokens.first == expectedToken) 42 | } 43 | 44 | private static var validNumbers: [String] = [ 45 | "1", 46 | "12.023", 47 | ".023", 48 | "1_000", 49 | "1_000.000_001", 50 | "1e-1_000", 51 | "7e+23", 52 | "0xfaff", 53 | "0xfa_ff", 54 | ] 55 | 56 | } 57 | 58 | struct SQLiteIdentifierTokenisingTests { 59 | 60 | let codeStorage: CodeStorage 61 | let codeStorageDelegate: CodeStorageDelegate 62 | 63 | init() { 64 | codeStorageDelegate = CodeStorageDelegate(with: .sqlite(), setText: { _ in }) 65 | codeStorage = CodeStorage(theme: .defaultLight) 66 | codeStorage.delegate = codeStorageDelegate 67 | } 68 | 69 | @Test("Recognises valid identifiers", arguments: validIdentifiers) 70 | func tokenisesAsIdentifier(text: String) throws { 71 | let attributedStringValue = NSAttributedString(string: text) 72 | codeStorage.setAttributedString(attributedStringValue) // this triggers tokenisation 73 | let lineMap = codeStorageDelegate.lineMap 74 | #expect(lineMap.lines.count == 1) 75 | 76 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "A token should be found") 77 | #expect(tokens.count == 1, "A single token should be found") 78 | 79 | let expectedToken = Tokeniser.Token( 80 | token: .identifier(nil), 81 | range: NSRange(location: 0, length: attributedStringValue.length) 82 | ) 83 | #expect(tokens.first == expectedToken) 84 | } 85 | 86 | @Test("Recognises invalid identifiers", arguments: invalidIdentifiers) 87 | func tokenisesAsNonIdentifier(text: String) throws { 88 | let attributedStringValue = NSAttributedString(string: text) 89 | codeStorage.setAttributedString(attributedStringValue) // this triggers tokenisation 90 | let lineMap = codeStorageDelegate.lineMap 91 | #expect(lineMap.lines.count == 1) 92 | 93 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "Tokens should be found") 94 | #expect(tokens.count > 1, "Multiple tokens should be found") 95 | } 96 | 97 | private static var validIdentifiers: [String] = [ 98 | "tbl", 99 | "_table", 100 | "a_table", 101 | "abc123", 102 | "😀", 103 | "z̷̡̢͚̺̗͉̖̝͇͈͙̮̻̏̑̆̓͌̒̕̚͝͝ͅͅͅắ̴̡̢͎͇̮̮̠̗̬̰̆͗̃̽̿̔͛̈̿̈̽̇̔̚̕l̸̨͈͔̦̩̠̤͖͚̗̻̥̻͕̭̞̝͆̏̅̕̚g̸͇͎̱̘̘̙̘͕͙͓͈̗̣͍̈́͋̔̓̒͜ͅo̸̝̘͓̾̅͌̂͛̃̈́̊͠͝", 104 | // Quoted identifiers (not keywords): 105 | "\"table\"", 106 | "[table]", 107 | "`table`", 108 | ] 109 | 110 | private static var invalidIdentifiers: [String] = [ 111 | "1table", 112 | "table>", 113 | "tab:le", 114 | ] 115 | } 116 | 117 | struct SQLiteStringTokenisingTests { 118 | 119 | let codeStorage: CodeStorage 120 | let codeStorageDelegate: CodeStorageDelegate 121 | 122 | init() { 123 | codeStorageDelegate = CodeStorageDelegate(with: .sqlite(), setText: { _ in }) 124 | codeStorage = CodeStorage(theme: .defaultLight) 125 | codeStorage.delegate = codeStorageDelegate 126 | } 127 | 128 | @Test("Recognises valid strings", arguments: validStrings) 129 | func tokenisesAsString(text: String) throws { 130 | let attributedStringValue = NSAttributedString(string: text) 131 | codeStorage.setAttributedString(attributedStringValue) // this triggers tokenisation 132 | let lineMap = codeStorageDelegate.lineMap 133 | #expect(lineMap.lines.count == 1) 134 | 135 | let tokens = try #require(lineMap.lookup(line: 0)?.info?.tokens, "A token should be found") 136 | #expect(tokens.count == 1, "A single token should be found") 137 | 138 | let expectedToken = Tokeniser.Token( 139 | token: .string, 140 | range: NSRange(location: 0, length: attributedStringValue.length) 141 | ) 142 | #expect(tokens.first == expectedToken) 143 | } 144 | 145 | private static var validStrings: [String] = [ 146 | "''", 147 | "'string'", 148 | "'str''ing'", 149 | "'str''i''ng'" 150 | ] 151 | } 152 | -------------------------------------------------------------------------------- /Tests/CodeEditorTests/TokenTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TokenTests.swift 3 | // 4 | // 5 | // Created by Manuel M T Chakravarty on 25/03/2023. 6 | // 7 | 8 | import RegexBuilder 9 | import XCTest 10 | @testable import CodeEditorView 11 | @testable import LanguageSupport 12 | 13 | final class TokenTests: XCTestCase { 14 | 15 | override func setUpWithError() throws { 16 | // Put setup code here. This method is called before the invocation of each test method in the class. 17 | } 18 | 19 | override func tearDownWithError() throws { 20 | // Put teardown code here. This method is called after the invocation of each test method in the class. 21 | } 22 | 23 | func testSimpleTokenise() throws { 24 | let code = 25 | """ 26 | // 15 "abc" 27 | let str = "xyz" 28 | """ 29 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 30 | codeStorage = CodeStorage(theme: .defaultLight) 31 | codeStorage.delegate = codeStorageDelegate 32 | 33 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 34 | 35 | let lineMap = codeStorageDelegate.lineMap 36 | XCTAssertEqual(lineMap.lines.count, 2) // code starts at line 0 37 | 38 | // Line 1 39 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 40 | [ Tokeniser.Token(token: .singleLineComment, range: NSRange(location: 0, length: 2)) 41 | , Tokeniser.Token(token: .number, range: NSRange(location: 3, length: 2)) 42 | , Tokeniser.Token(token: .string, range: NSRange(location: 6, length: 5))]) 43 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.commentRanges, [NSRange(location: 0, length: 12)]) 44 | 45 | // Line 2 46 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.tokens, 47 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 3)) 48 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 4, length: 3)) 49 | , Tokeniser.Token(token: .symbol, range: NSRange(location: 8, length: 1)) 50 | , Tokeniser.Token(token: .string, range: NSRange(location: 10, length: 5))]) 51 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.commentRanges, []) 52 | } 53 | 54 | func testTokeniseAllComment() throws { 55 | let code = 56 | """ 57 | // 58 | // A Test 59 | """ 60 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 61 | codeStorage = CodeStorage(theme: .defaultLight) 62 | codeStorage.delegate = codeStorageDelegate 63 | 64 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 65 | 66 | let lineMap = codeStorageDelegate.lineMap 67 | XCTAssertEqual(lineMap.lines.count, 2) // code starts at line 1 68 | 69 | // Line 1 70 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 71 | [ Tokeniser.Token(token: .singleLineComment, range: NSRange(location: 0, length: 2))]) 72 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.commentRanges, [NSRange(location: 0, length: 3)]) 73 | 74 | // Line 2 75 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.tokens, 76 | [ Tokeniser.Token(token: .singleLineComment, range: NSRange(location: 0, length: 2)) 77 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 3, length: 1)) 78 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 5, length: 4))]) 79 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.commentRanges, [NSRange(location: 0, length: 9)]) 80 | } 81 | 82 | func testTokeniseWithNewline() throws { 83 | let code = 84 | """ 85 | // 15 "abc" 86 | let str = "xyz"\n 87 | """ 88 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 89 | codeStorage = CodeStorage(theme: .defaultLight) 90 | codeStorage.delegate = codeStorageDelegate 91 | 92 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 93 | 94 | let lineMap = codeStorageDelegate.lineMap 95 | XCTAssertEqual(lineMap.lines.count, 3) // code starts at line 1 96 | 97 | // Line 3 98 | XCTAssertEqual(lineMap.lookup(line: 2)?.info?.tokens, []) 99 | XCTAssertEqual(lineMap.lookup(line: 2)?.info?.commentRanges, []) 100 | } 101 | 102 | func testTokeniseCommentAtEnd() throws { 103 | let code = 104 | """ 105 | let str = "xyz" // 15 "abc" 106 | test 107 | """ 108 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 109 | codeStorage = CodeStorage(theme: .defaultLight) 110 | codeStorage.delegate = codeStorageDelegate 111 | 112 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 113 | 114 | let lineMap = codeStorageDelegate.lineMap 115 | XCTAssertEqual(lineMap.lines.count, 2) // code starts at line 1 116 | 117 | // Line 1 118 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 119 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 3)) 120 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 4, length: 3)) 121 | , Tokeniser.Token(token: .symbol, range: NSRange(location: 8, length: 1)) 122 | , Tokeniser.Token(token: .string, range: NSRange(location: 10, length: 5)) 123 | , Tokeniser.Token(token: .singleLineComment, range: NSRange(location: 16, length: 2)) 124 | , Tokeniser.Token(token: .number, range: NSRange(location: 19, length: 2)) 125 | , Tokeniser.Token(token: .string, range: NSRange(location: 22, length: 5))]) 126 | 127 | // Comment ranges 128 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.commentRanges, [NSRange(location: 16, length: 12)]) 129 | } 130 | 131 | func testTokeniseCommentAtEndMulti() throws { 132 | let code = 133 | """ 134 | let str = "xyz" 135 | let x = 15 // 15 "abc" 136 | test 137 | """ 138 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 139 | codeStorage = CodeStorage(theme: .defaultLight) 140 | codeStorage.delegate = codeStorageDelegate 141 | 142 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 143 | 144 | let lineMap = codeStorageDelegate.lineMap 145 | XCTAssertEqual(lineMap.lines.count, 3) // code starts at line 1 146 | 147 | // Line 1 148 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 149 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 3)) 150 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 4, length: 3)) 151 | , Tokeniser.Token(token: .symbol, range: NSRange(location: 8, length: 1)) 152 | , Tokeniser.Token(token: .string, range: NSRange(location: 10, length: 5))]) 153 | // Line 2 154 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.tokens, 155 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 2, length: 3)) 156 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 6, length: 1)) 157 | , Tokeniser.Token(token: .symbol, range: NSRange(location: 8, length: 1)) 158 | , Tokeniser.Token(token: .number, range: NSRange(location: 10, length: 2)) 159 | , Tokeniser.Token(token: .singleLineComment, range: NSRange(location: 13, length: 2)) 160 | , Tokeniser.Token(token: .number, range: NSRange(location: 16, length: 2)) 161 | , Tokeniser.Token(token: .string, range: NSRange(location: 19, length: 5))]) 162 | 163 | // Comment ranges 164 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.commentRanges, []) 165 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.commentRanges, [NSRange(location: 13, length: 12)]) 166 | XCTAssertEqual(lineMap.lookup(line: 2)?.info?.commentRanges, []) 167 | } 168 | 169 | func testTokeniseNestedComment() throws { 170 | let code = 171 | """ 172 | let str = "xyz" /* 15 "abc"*/ 173 | test 174 | """ 175 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 176 | codeStorage = CodeStorage(theme: .defaultLight) 177 | codeStorage.delegate = codeStorageDelegate 178 | 179 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 180 | 181 | let lineMap = codeStorageDelegate.lineMap 182 | XCTAssertEqual(lineMap.lines.count, 2) // code starts at line 1 183 | 184 | // Line 1 185 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 186 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 3)) 187 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 4, length: 3)) 188 | , Tokeniser.Token(token: .symbol, range: NSRange(location: 8, length: 1)) 189 | , Tokeniser.Token(token: .string, range: NSRange(location: 10, length: 5)) 190 | , Tokeniser.Token(token: .nestedCommentOpen, range: NSRange(location: 16, length: 2)) 191 | , Tokeniser.Token(token: .nestedCommentClose, range: NSRange(location: 27, length: 2))]) 192 | 193 | // Comment ranges 194 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.commentRanges, [NSRange(location: 16, length: 13)]) 195 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.commentRanges, []) 196 | } 197 | 198 | func testTokeniseMultiLineNestedComment() throws { 199 | let code = 200 | """ 201 | let str = "xyz" /* 15 "abc" 202 | test */ 203 | """ 204 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 205 | codeStorage = CodeStorage(theme: .defaultLight) 206 | codeStorage.delegate = codeStorageDelegate 207 | 208 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 209 | 210 | let lineMap = codeStorageDelegate.lineMap 211 | XCTAssertEqual(lineMap.lines.count, 2) // code starts at line 1 212 | 213 | // Line 1 214 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 215 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 3)) 216 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 4, length: 3)) 217 | , Tokeniser.Token(token: .symbol, range: NSRange(location: 8, length: 1)) 218 | , Tokeniser.Token(token: .string, range: NSRange(location: 10, length: 5)) 219 | , Tokeniser.Token(token: .nestedCommentOpen, range: NSRange(location: 16, length: 2))]) 220 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.tokens, 221 | [ Tokeniser.Token(token: .nestedCommentClose, range: NSRange(location: 5, length: 2))]) 222 | 223 | // Comment ranges 224 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.commentRanges, [NSRange(location: 16, length: 12)]) 225 | XCTAssertEqual(lineMap.lookup(line: 1)?.info?.commentRanges, [NSRange(location: 0, length: 7)]) 226 | } 227 | 228 | func testCaseInsensitiveReservedIdentifiersUnspecified() throws { 229 | let lowerCaseCode = "struct SomeType {}" 230 | let codeStorageDelegate = CodeStorageDelegate(with: .swift(), setText: { _ in }), 231 | codeStorage = CodeStorage(theme: .defaultLight) 232 | codeStorage.delegate = codeStorageDelegate 233 | 234 | codeStorage.setAttributedString(NSAttributedString(string: lowerCaseCode)) // this triggers tokenisation 235 | 236 | let lowerCaseLineMap = codeStorageDelegate.lineMap 237 | XCTAssertEqual(lowerCaseLineMap.lines.count, 1) // code starts at line 1 238 | XCTAssertEqual(lowerCaseLineMap.lookup(line: 0)?.info?.tokens, 239 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 6)) 240 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 7, length: 8)) 241 | , Tokeniser.Token(token: .curlyBracketOpen, range: NSRange(location: 16, length: 1)) 242 | , Tokeniser.Token(token: .curlyBracketClose, range: NSRange(location: 17, length: 1))]) 243 | 244 | let upperCaseCode = "STRUCT SomeType {}" 245 | codeStorage.setAttributedString(NSAttributedString(string: upperCaseCode)) // this triggers tokenisation 246 | 247 | let upperCaseLineMap = codeStorageDelegate.lineMap 248 | XCTAssertEqual(upperCaseLineMap.lines.count, 1) // code starts at line 1 249 | XCTAssertEqual(upperCaseLineMap.lookup(line: 0)?.info?.tokens, 250 | [ Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 0, length: 6)) 251 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 7, length: 8)) 252 | , Tokeniser.Token(token: .curlyBracketOpen, range: NSRange(location: 16, length: 1)) 253 | , Tokeniser.Token(token: .curlyBracketClose, range: NSRange(location: 17, length: 1))]) 254 | } 255 | 256 | func testCaseInsensitiveReservedIdentifiersFalse() throws { 257 | let lowerCaseCode = "struct SomeType {}" 258 | let codeStorageDelegate = CodeStorageDelegate(with: .structCaseSensitive, setText: { _ in }), 259 | codeStorage = CodeStorage(theme: .defaultLight) 260 | codeStorage.delegate = codeStorageDelegate 261 | 262 | codeStorage.setAttributedString(NSAttributedString(string: lowerCaseCode)) // this triggers tokenisation 263 | 264 | let lowerCaseLineMap = codeStorageDelegate.lineMap 265 | XCTAssertEqual(lowerCaseLineMap.lines.count, 1) // code starts at line 1 266 | XCTAssertEqual(lowerCaseLineMap.lookup(line: 0)?.info?.tokens, 267 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 6)) 268 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 7, length: 8)) 269 | , Tokeniser.Token(token: .curlyBracketOpen, range: NSRange(location: 16, length: 1)) 270 | , Tokeniser.Token(token: .curlyBracketClose, range: NSRange(location: 17, length: 1))]) 271 | 272 | let upperCaseCode = "STRUCT SomeType {}" 273 | codeStorage.setAttributedString(NSAttributedString(string: upperCaseCode)) // this triggers tokenisation 274 | 275 | let upperCaseLineMap = codeStorageDelegate.lineMap 276 | XCTAssertEqual(upperCaseLineMap.lines.count, 1) // code starts at line 1 277 | XCTAssertEqual(upperCaseLineMap.lookup(line: 0)?.info?.tokens, 278 | [ Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 0, length: 6)) 279 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 7, length: 8)) 280 | , Tokeniser.Token(token: .curlyBracketOpen, range: NSRange(location: 16, length: 1)) 281 | , Tokeniser.Token(token: .curlyBracketClose, range: NSRange(location: 17, length: 1))]) 282 | } 283 | 284 | func testCaseInsensitiveReservedIdentifiersTrue() throws { 285 | let code = "STRUCT SomeType {}" 286 | let codeStorageDelegate = CodeStorageDelegate(with: .structCaseInsensitive, setText: { _ in }), 287 | codeStorage = CodeStorage(theme: .defaultLight) 288 | codeStorage.delegate = codeStorageDelegate 289 | 290 | codeStorage.setAttributedString(NSAttributedString(string: code)) // this triggers tokenisation 291 | 292 | let lineMap = codeStorageDelegate.lineMap 293 | XCTAssertEqual(lineMap.lines.count, 1) // code starts at line 1 294 | XCTAssertEqual(lineMap.lookup(line: 0)?.info?.tokens, 295 | [ Tokeniser.Token(token: .keyword, range: NSRange(location: 0, length: 6)) 296 | , Tokeniser.Token(token: .identifier(.none), range: NSRange(location: 7, length: 8)) 297 | , Tokeniser.Token(token: .curlyBracketOpen, range: NSRange(location: 16, length: 1)) 298 | , Tokeniser.Token(token: .curlyBracketClose, range: NSRange(location: 17, length: 1))]) 299 | } 300 | 301 | static var allTests = [ 302 | ("testSimpleTokenise", testSimpleTokenise), 303 | ("testTokeniseAllComment", testTokeniseAllComment), 304 | ("testTokeniseWithNewline", testTokeniseWithNewline), 305 | ("testTokeniseCommentAtEnd", testTokeniseCommentAtEnd), 306 | ("testTokeniseCommentAtEndMulti", testTokeniseCommentAtEndMulti), 307 | ("testTokeniseNestedComment", testTokeniseNestedComment), 308 | ("testTokeniseMultiLineNestedComment", testTokeniseMultiLineNestedComment), 309 | ("testCaseInsensitiveReservedIdentifiersUnspecified", testCaseInsensitiveReservedIdentifiersUnspecified), 310 | ("testCaseInsensitiveReservedIdentifiersFalse", testCaseInsensitiveReservedIdentifiersFalse), 311 | ("testCaseInsensitiveReservedIdentifiersTrue", testCaseInsensitiveReservedIdentifiersTrue), 312 | ] 313 | } 314 | 315 | extension LanguageConfiguration { 316 | private static let plainIdentifierRegex: Regex = Regex { 317 | identifierHeadCharacters 318 | ZeroOrMore { 319 | identifierCharacters 320 | } 321 | } 322 | 323 | fileprivate static var structCaseSensitive: LanguageConfiguration { 324 | LanguageConfiguration( 325 | name: "StructCaseUnspecified", 326 | supportsSquareBrackets: true, 327 | supportsCurlyBrackets: true, 328 | caseInsensitiveReservedIdentifiers: false, 329 | stringRegex: nil, 330 | characterRegex: nil, 331 | numberRegex: nil, 332 | singleLineComment: nil, 333 | nestedComment: nil, 334 | identifierRegex: plainIdentifierRegex, 335 | operatorRegex: nil, 336 | reservedIdentifiers: ["struct"], 337 | reservedOperators: [], 338 | languageService: nil 339 | ) 340 | } 341 | 342 | fileprivate static var structCaseInsensitive: LanguageConfiguration { 343 | LanguageConfiguration( 344 | name: "StructCaseInsensitive", 345 | supportsSquareBrackets: true, 346 | supportsCurlyBrackets: true, 347 | caseInsensitiveReservedIdentifiers: true, 348 | stringRegex: nil, 349 | characterRegex: nil, 350 | numberRegex: nil, 351 | singleLineComment: nil, 352 | nestedComment: nil, 353 | identifierRegex: plainIdentifierRegex, 354 | operatorRegex: nil, 355 | reservedIdentifiers: ["struct"], 356 | reservedOperators: [], 357 | languageService: nil 358 | ) 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /Tests/CodeEditorTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(LineMapTests.allTests), 7 | testCase(TokenTests.allTests), 8 | testCase(CodeEditorTests.allTests), 9 | ] 10 | } 11 | #endif 12 | -------------------------------------------------------------------------------- /app-demo-images/iOS-light-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mchakravarty/CodeEditorView/977fa2450be4269dd79d54d3ac62bc5d55b0da3f/app-demo-images/iOS-light-example.png -------------------------------------------------------------------------------- /app-demo-images/macOS-dark-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mchakravarty/CodeEditorView/977fa2450be4269dd79d54d3ac62bc5d55b0da3f/app-demo-images/macOS-dark-example.png --------------------------------------------------------------------------------