Browse Source

JavaScript Highlighter: significantly improve performance

master
Shadowfacts 2 months ago
parent
commit
6b39e90ea4
Signed by: Shadowfacts <me@shadowfacts.net> GPG Key ID: 94A5AB95422746E5

+ 191
- 87
MongoView/Synax Highlighting/JavaScriptHighlighter.swift View File

@@ -7,6 +7,9 @@
7 7
 //
8 8
 
9 9
 import AppKit
10
+import OSLog
11
+
12
+fileprivate let log = OSLog(subsystem: "space.vaccor.MongoView.JavaScriptHighlighter", category: .pointsOfInterest)
10 13
 
11 14
 fileprivate let identifiers: CharacterSet = {
12 15
     var set = CharacterSet.alphanumerics
@@ -22,29 +25,32 @@ fileprivate let operators = CharacterSet(charactersIn: "+-*/<>=")
22 25
 fileprivate let expressionEnds = CharacterSet(charactersIn: ",]});")
23 26
 
24 27
 class JavaScriptHighlighter {
25
-    private let text: String
26
-    private var attributed: NSMutableAttributedString
28
+    private var text: String!
29
+    private var attributed: NSMutableAttributedString!
27 30
     private var currentIndex: String.Index!
28
-    private var indent = ""
31
+    private var _indent = ""
29 32
     private(set) var tokens = [(token: TokenType, range: NSRange)]()
30 33
     var debug = false
31
-    
32
-    init(text: String) {
33
-        self.text = text
34
-        self.attributed = NSMutableAttributedString(attributedString: NSAttributedString(string: text))
35
-    }
36
-    
37
-    init(mutableAttributed: NSMutableAttributedString) {
38
-        self.text = mutableAttributed.string
39
-        self.attributed = mutableAttributed
40
-    }
41
-    
34
+
42 35
     private func print(_ str: @autoclosure () -> String) {
36
+        #if DEBUG
43 37
         if debug {
44
-            Swift.print("\(indent)\(str())")
38
+            Swift.print("\(_indent)\(str())")
45 39
         }
40
+        #endif
46 41
     }
47 42
     
43
+    private func indent() {
44
+        #if DEBUG
45
+        _indent += "  "
46
+        #endif
47
+    }
48
+    private func outdent() {
49
+        #if DEBUG
50
+        _indent = String(_indent.dropLast(2))
51
+        #endif
52
+    }
53
+
48 54
     private func range(from: String.Index, to: String.Index) -> NSRange {
49 55
         return NSRange(from..<to, in: text)
50 56
     }
@@ -65,14 +71,28 @@ class JavaScriptHighlighter {
65 71
         return text[currentIndex..<text.index(currentIndex, offsetBy: realLength)]
66 72
     }
67 73
     
68
-    @discardableResult
69
-    private func consume() -> Unicode.Scalar? {
70
-        let c = peek()
74
+    private func consume() {
71 75
         currentIndex = text.index(after: currentIndex)
76
+    }
77
+    
78
+    private func getAndConsume() -> Unicode.Scalar? {
79
+        let c = peek()
80
+        consume()
72 81
         return c
73 82
     }
74 83
     
75
-    func highlight() {
84
+    func highlight(text: String) {
85
+        self.highlight(attributed: NSMutableAttributedString(attributedString: NSAttributedString(string: text)))
86
+    }
87
+    
88
+    func highlight(attributed: NSMutableAttributedString) {
89
+        self.attributed = attributed
90
+        self.text = attributed.string
91
+        self.tokens = []
92
+        
93
+        os_signpost(.begin, log: log, name: "highlight")
94
+        defer { os_signpost(.end, log: log, name: "highlight") }
95
+        
76 96
         attributed.beginEditing()
77 97
         let fullRange = NSRange(location: 0, length: attributed.length)
78 98
         attributed.setAttributes([
@@ -88,19 +108,95 @@ class JavaScriptHighlighter {
88 108
         attributed.endEditing()
89 109
     }
90 110
     
111
+    func inserted(character: Unicode.Scalar, index: Int) -> Bool {
112
+        os_signpost(.begin, log: log, name: "inserted(character:index:)")
113
+        defer { os_signpost(.end, log: log, name: "inserted(character:index:)") }
114
+
115
+        if let (token, range) = tokenAndRange(fullyContaining: index) {
116
+            switch token {
117
+            case .identifier:
118
+                let set = range.location == index ? identifierStarts : identifiers
119
+                guard set.contains(character) else { return false }
120
+            case let .string(stringChar):
121
+                guard character != stringChar || (index > 0 && Unicode.Scalar((text as NSString).character(at: index - 1)) == "\\"),
122
+                    character != "\\" else { return false }
123
+            case .number:
124
+                if character == "." && (attributed.string as NSString).substring(with: range).contains(Character(".")) {
125
+                    return false
126
+                }
127
+            default:
128
+                return false
129
+            }
130
+            var attributes: [NSAttributedString.Key: Any] = [
131
+                .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
132
+            ]
133
+            if let color = token.color {
134
+                attributes[.foregroundColor] = color
135
+            }
136
+            attributed.addAttributes(attributes, range: NSRange(location: index, length: 1))
137
+            tokens = tokens.map { (token, range) in
138
+                if range.contains(index) {
139
+                    return (token, NSRange(location: range.location, length: range.length + 1))
140
+                } else if range.location >= index {
141
+                    return (token, NSRange(location: range.location + 1, length: range.length))
142
+                } else {
143
+                    return (token, range)
144
+                }
145
+            }
146
+            return true
147
+        }
148
+        return false
149
+    }
150
+    
151
+    func removed(index: Int) -> Bool {
152
+        os_signpost(.begin, log: log, name: "removed(at:)")
153
+        defer { os_signpost(.end, log: log, name: "removed(at:)") }
154
+
155
+        if let (token, range) = tokenAndRange(fullyContaining: index) {
156
+            switch token {
157
+            case .string(_):
158
+                guard index > range.location && index < range.location + range.length - 1 else { return false }
159
+                if index > 0 && Unicode.Scalar((text as NSString).character(at: index - 1)) == "\\" {
160
+                    // removed character
161
+                    return false
162
+                }
163
+            case .number:
164
+                break
165
+            default:
166
+                return false
167
+            }
168
+            tokens = tokens.map { (token, range) in
169
+                if range.contains(index) {
170
+                    return (token, NSRange(location: range.location, length: range.length - 1))
171
+                } else if range.location >= index {
172
+                    return (token, NSRange(location: range.location - 1, length: range.length))
173
+                } else {
174
+                    return (token, range)
175
+                }
176
+            }
177
+            return true
178
+        }
179
+        return false
180
+    }
181
+    
182
+    func token(at index: Int) -> TokenType? {
183
+        for (token, range) in tokens where range.contains(index) {
184
+            return token
185
+        }
186
+        return nil
187
+    }
188
+    
189
+    private func tokenAndRange(fullyContaining index: Int) -> (TokenType, NSRange)? {
190
+        for (token, range) in tokens where index > range.location && index < range.location + range.length {
191
+            return (token, range)
192
+        }
193
+        return nil
194
+    }
195
+    
91 196
     private func emit(token: TokenType, range: NSRange) {
92
-        let color: NSColor
93
-        switch token {
94
-        case .string:
95
-            color = .systemRed
96
-        case .number:
97
-            color = .systemBlue
98
-        case .punctuation:
99
-            color = .systemTeal
100
-        case .identifier:
101
-            return
197
+        if let color = token.color {
198
+            attributed.addAttribute(.foregroundColor, value: color, range: range)
102 199
         }
103
-        attributed.addAttribute(.foregroundColor, value: color, range: range)
104 200
         tokens.append((token, range))
105 201
     }
106 202
 
@@ -129,6 +225,8 @@ class JavaScriptHighlighter {
129 225
             consumeDotLookup()
130 226
         } else if char == "?" {
131 227
             consumeTernaryExpression()
228
+        } else if expressionEnds.contains(char) {
229
+            return
132 230
         } else {
133 231
             consume()
134 232
         }
@@ -168,15 +266,20 @@ class JavaScriptHighlighter {
168 266
     
169 267
     private func consumeString() {
170 268
         let stringStart = currentIndex!
171
-        let startChar = consume()
172
-        while currentIndex < text.endIndex && peek() != startChar && (currentIndex == text.startIndex || text[text.index(before: currentIndex)] != "\\") {
269
+        let quote = peek()!
270
+        consume() // opening quote
271
+        var prevChar: Unicode.Scalar?
272
+        var char = peek()
273
+        while currentIndex < text.endIndex {
173 274
             consume()
174
-        }
175
-        if currentIndex < text.endIndex {
176
-            consume() // string closing quote
275
+            if char == quote && prevChar != "\\" {
276
+                break
277
+            }
278
+            prevChar = char
279
+            char = peek()
177 280
         }
178 281
         print("String: \(text[stringStart..<currentIndex])")
179
-        emit(token: .string, range: range(from: stringStart, to: currentIndex))
282
+        emit(token: .string(quote), range: range(from: stringStart, to: currentIndex))
180 283
     }
181 284
     
182 285
     private func consumeTemplateString() {
@@ -185,14 +288,14 @@ class JavaScriptHighlighter {
185 288
         func emitTemplateStringFragment() {
186 289
             guard stringFragmentStart != currentIndex else { return }
187 290
             print("Template string fragment: '\(text[stringFragmentStart!..<currentIndex])'")
188
-            emit(token: .string, range: range(from: stringFragmentStart!, to: currentIndex))
291
+            emit(token: .string("`"), range: range(from: stringFragmentStart!, to: currentIndex))
189 292
             stringFragmentStart = currentIndex
190 293
         }
191 294
         
192 295
         consume() // opening `
193 296
         
194
-        while currentIndex < text.endIndex {
195
-            if peek() == "$" {
297
+        while let char = peek() {
298
+            if char == "$" {
196 299
                 emitTemplateStringFragment()
197 300
                 consume() // $
198 301
                 if peek() == "{" {
@@ -205,7 +308,7 @@ class JavaScriptHighlighter {
205 308
                     }
206 309
 
207 310
                 }
208
-            } else if peek() == "`" {
311
+            } else if char == "`" {
209 312
                 stringFragmentStart = stringFragmentStart ?? currentIndex
210 313
                 consume() // `
211 314
                 emitTemplateStringFragment()
@@ -221,11 +324,11 @@ class JavaScriptHighlighter {
221 324
     }
222 325
     
223 326
     private func consumeTemplateStringExpression() {
224
-        indent += "  "
327
+        indent()
225 328
         while currentIndex < text.endIndex && peek() != "}" {
226 329
             consumeExpression()
227 330
         }
228
-        indent = String(indent.dropLast(2))
331
+        outdent()
229 332
     }
230 333
 
231 334
     private func consumeOperator() {
@@ -237,11 +340,11 @@ class JavaScriptHighlighter {
237 340
         consume() // (
238 341
         print("Opening (")
239 342
         emit(token: .punctuation, range: prevCharRange())
240
-        indent += "  "
343
+        indent()
241 344
         while currentIndex < text.endIndex && peek() != ")" {
242 345
             consumeExpression()
243 346
         }
244
-        indent = String(indent.dropLast(2))
347
+        outdent()
245 348
         if currentIndex < text.endIndex {
246 349
             consume() // )
247 350
             print("Closing )")
@@ -253,34 +356,29 @@ class JavaScriptHighlighter {
253 356
         consume() // {
254 357
         print("Opening {")
255 358
         emit(token: .punctuation, range: prevCharRange())
256
-        indent += "  "
257
-        object:
258
-        while currentIndex < text.endIndex && peek() != "}" {
259
-            consumeObjectKey()
260
-            if peek() == ":" {
261
-                consume() // :
359
+        indent()
360
+        while let char = peek() {
361
+            if char == "}" {
362
+                consume() // }
363
+                print("Closing }")
262 364
                 emit(token: .punctuation, range: prevCharRange())
263
-                consumeWhitespace()
264
-                while currentIndex < text.endIndex && peek() != "," && peek() != "}" {
265
-                    consumeExpression()
266
-                }
267
-            }
268
-            consumeWhitespace()
269
-
270
-            if peek() == "," {
271
-                consume() // ,
365
+                break
366
+            } else if char == "'" || char == "\"" {
367
+                let keyStart = currentIndex!
368
+                consumeString()
369
+                print("Object key: '\(text[keyStart..<currentIndex])'")
370
+            } else if identifierStarts.contains(char) {
371
+                let keyStart = currentIndex!
372
+                consumeIdentifier()
373
+                print("Object key: '\(text[keyStart..<currentIndex])'")
374
+            } else if char == ":" || char == "," {
375
+                consume() // :
272 376
                 emit(token: .punctuation, range: prevCharRange())
273
-                continue
274 377
             } else {
275
-                break
378
+                consumeExpression()
276 379
             }
277 380
         }
278
-        indent = String(indent.dropLast(2))
279
-        if currentIndex < text.endIndex {
280
-            consume() // }
281
-            print("Closing }")
282
-            emit(token: .punctuation, range: prevCharRange())
283
-        }
381
+        outdent()
284 382
     }
285 383
     
286 384
     private func consumeObjectKey() {
@@ -317,35 +415,28 @@ class JavaScriptHighlighter {
317 415
         consume() // [
318 416
         print("Opening [")
319 417
         emit(token: .punctuation, range: prevCharRange())
320
-        indent += "  "
321
-        array:
322
-        while currentIndex < text.endIndex {
323
-            consumeWhitespace()
324
-            if peek() == "," {
418
+        indent()
419
+        while let char = peek() {
420
+            if char == "]" {
421
+                consume() // ]
422
+                print("Closing ]")
423
+                emit(token: .punctuation, range: prevCharRange())
424
+                break
425
+            } else if char == "," {
325 426
                 consume() // ,
326 427
                 print("Array separator")
327 428
                 emit(token: .punctuation, range: prevCharRange())
328
-            } else if peek() == "]" {
329
-                break array
330 429
             } else {
331
-                print("Array element")
332
-                while let char = peek(), char != ",", char != "]" {
333
-                    consumeExpression()
334
-                }
430
+                consumeExpression()
335 431
             }
336 432
         }
337
-        indent = String(indent.dropLast(2))
338
-        if currentIndex < text.endIndex {
339
-            consume() // ]
340
-            print("Closing ]")
341
-            emit(token: .punctuation, range: prevCharRange())
342
-        }
433
+        outdent()
343 434
     }
344 435
     
345 436
     func consumeTernaryExpression() {
346 437
         consume() // ?
347 438
         print("Ternary expression")
348
-        indent += "  "
439
+        indent()
349 440
         print("Ternary true result")
350 441
         while let char = peek(), char != ":" {
351 442
             consumeExpression() // true result
@@ -355,7 +446,7 @@ class JavaScriptHighlighter {
355 446
         while let char = peek(), !expressionEnds.contains(char) {
356 447
             consumeExpression()
357 448
         }
358
-        indent = String(indent.dropLast(2))
449
+        outdent()
359 450
     }
360 451
     
361 452
 }
@@ -365,6 +456,19 @@ extension JavaScriptHighlighter {
365 456
         case identifier
366 457
         case punctuation
367 458
         case number
368
-        case string
459
+        case string(Unicode.Scalar)
460
+        
461
+        var color: NSColor? {
462
+            switch self {
463
+            case .string(_):
464
+                return .systemRed
465
+            case .number:
466
+                return .systemBlue
467
+            case .punctuation:
468
+                return .systemTeal
469
+            case .identifier:
470
+                return nil
471
+            }
472
+        }
369 473
     }
370 474
 }

+ 60
- 23
MongoView/Views/JavaScriptEditorView.swift View File

@@ -10,24 +10,37 @@ import AppKit
10 10
 
11 11
 class JavaScriptEditorView: NSTextView {
12 12
     
13
-    var highlighter: JavaScriptHighlighter?
13
+    var highlighter = JavaScriptHighlighter()
14
+    private var isRehighlighting = false
14 15
     
15 16
     override var string: String {
16
-        didSet {
17
+        get {
18
+            super.string
19
+        }
20
+        set {
21
+            isRehighlighting = true
22
+            super.string = newValue
17 23
             rehighlight()
18 24
         }
19 25
     }
20 26
 
21 27
     func rehighlight() {
22
-        highlighter = JavaScriptHighlighter(mutableAttributed: self.textStorage!)
23
-        highlighter!.highlight()
28
+        isRehighlighting = true
29
+        highlighter.highlight(attributed: self.textStorage!)
30
+        isRehighlighting = false
31
+    }
32
+    
33
+    override func awakeFromNib() {
34
+        super.awakeFromNib()
35
+        
36
+        textStorage!.delegate = self
24 37
     }
25 38
     
26 39
     override func shouldChangeText(in affectedCharRange: NSRange, replacementString: String?) -> Bool {
27 40
         guard super.shouldChangeText(in: affectedCharRange, replacementString: replacementString) else {
28 41
             return false
29 42
         }
30
-        
43
+
31 44
         if affectedCharRange.length == 0,
32 45
             let string = replacementString,
33 46
             string.count == 1,
@@ -37,17 +50,12 @@ class JavaScriptEditorView: NSTextView {
37 50
 
38 51
         return true
39 52
     }
40
-
41
-    override func didChangeText() {
42
-        rehighlight()
43
-        
44
-        super.didChangeText()
45
-    }
46 53
     
47 54
     func tryAutocompleteCharacter(for range: NSRange, string inserted: String) -> Bool {
48 55
         if let end = autocompleteResultFor(string: inserted, in: range) {
49 56
             textStorage!.insert(NSAttributedString(string: "\(inserted)\(end)"), at: range.location)
50
-            didChangeText()
57
+            rehighlight()
58
+            super.didChangeText()
51 59
             setSelectedRange(NSRange(location: range.location + 1, length: 0))
52 60
             return true
53 61
         } else {
@@ -58,28 +66,57 @@ class JavaScriptEditorView: NSTextView {
58 66
     private func autocompleteResultFor(string: String, in range: NSRange) -> String? {
59 67
         switch string {
60 68
         case "'", "\"", "`":
61
-            return token(at: range.location) != .string ? string : nil
69
+            if case .string(_) = highlighter.token(at: range.location) {
70
+                return nil
71
+            } else {
72
+                return string
73
+            }
62 74
         case "(":
63
-            return token(at: range.location) != .string ? ")" : nil
75
+            if case .string(_) = highlighter.token(at: range.location) {
76
+                return nil
77
+            } else {
78
+                return ")"
79
+            }
64 80
         case "[":
65
-            return token(at: range.location) != .string ? "]" : nil
81
+            if case .string(_) = highlighter.token(at: range.location) {
82
+                return nil
83
+            } else {
84
+                return "]"
85
+            }
66 86
         case "{":
67
-            var prevChar: Unicode.Scalar?
68 87
             if range.location > 0 {
69 88
                 let index = self.string.index(self.string.startIndex, offsetBy: range.location - 1)
70
-                prevChar = self.string.unicodeScalars[index]
89
+                let prevChar = self.string.unicodeScalars[index]
90
+                if prevChar == "$" {
91
+                    return "}"
92
+                }
93
+            }
94
+            if case .string(_) = highlighter.token(at: range.location) {
95
+                return nil
96
+            } else {
97
+                return "}"
71 98
             }
72
-            return token(at: range.location) != .string || (prevChar == "$") ? "}" : nil
73 99
         default:
74 100
             return nil
75 101
         }
76 102
     }
77 103
 
78
-    private func token(at index: Int) -> JavaScriptHighlighter.TokenType? {
79
-        guard let highlighter = highlighter else { return nil }
80
-        for (token, range) in highlighter.tokens where range.contains(index) {
81
-            return token
104
+}
105
+
106
+extension JavaScriptEditorView: NSTextStorageDelegate {
107
+    func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int) {
108
+        guard editedMask.contains(.editedCharacters), !isRehighlighting else { return }
109
+        if delta == 1 {
110
+            if let char = Unicode.Scalar((textStorage.string as NSString).character(at: editedRange.location)),
111
+                highlighter.inserted(character: char, index: editedRange.location) {
112
+                return
113
+            }
114
+        } else if delta == -1 {
115
+            if highlighter.removed(index: editedRange.location) {
116
+                return
117
+            }
82 118
         }
83
-        return nil
119
+        
120
+        rehighlight()
84 121
     }
85 122
 }

Loading…
Cancel
Save