/
highlighter.py
363 lines (311 loc) · 14.1 KB
/
highlighter.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
#
# Copyright 2018 DreamWorks Animation L.L.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Custom syntax highlighters.
"""
import inspect
import re
from Qt import QtCore, QtGui
from .constants import LINE_CHAR_LIMIT
from .utils import findModules
# Enabled when running in a theme with a dark background color.
DARK_THEME = False
def createRule(pattern, color=None, darkColor=None, weight=None, italic=False, cs=QtCore.Qt.CaseSensitive):
""" Create a single-line syntax highlighting rule.
:Parameters:
pattern : `str`
RegEx to match
color : `QtGui.QColor`
Color to highlight matches when in a light background theme
darkColor : `QtGui.QColor`
Color to highlight matches when in a dark background theme.
Defaults to color if not given.
weight : `int` | None
Optional font weight for matches
italic : `bool`
Set the font to italic
cs : `int`
Case sensitivity for RegEx matching
:Returns:
Tuple of `QtCore.QRegExp` and `QtGui.QTextCharFormat` objects.
:Rtype:
tuple
"""
frmt = QtGui.QTextCharFormat()
if DARK_THEME and darkColor is not None:
frmt.setForeground(darkColor)
elif color is not None:
frmt.setForeground(color)
if weight is not None:
frmt.setFontWeight(weight)
if italic:
frmt.setFontItalic(True)
return QtCore.QRegExp(pattern, cs), frmt
def createMultilineRule(startPattern, endPattern, color=None, darkColor=None, weight=None, italic=False, cs=QtCore.Qt.CaseSensitive):
""" Create a multiline syntax highlighting rule.
:Parameters:
startPattern : `str`
RegEx to match for the start of the block of lines.
endPattern : `str`
RegEx to match for the end of the block of lines.
color : `QtGui.QColor`
Color to highlight matches
darkColor : `QtGui.QColor`
Color to highlight matches when in a dark background theme.
weight : `int` | None
Optional font weight for matches
italic : `bool`
Set the font to italic
cs : `int`
Case sensitivity for RegEx matching
:Returns:
Tuple of `QtCore.QRegExp` and `QtGui.QTextCharFormat` objects.
:Rtype:
tuple
"""
start, frmt = createRule(startPattern, color, darkColor, weight, italic, cs)
end = QtCore.QRegExp(endPattern, cs)
return start, end, frmt
def findHighlighters():
""" Get the installed highlighter classes.
:Returns:
List of `MasterHighlighter` objects
:Rtype:
`list`
"""
# Find all available "MasterHighlighter" subclasses within the highlighters module.
classes = []
for module in findModules("highlighters"):
for _, cls in inspect.getmembers(module, lambda x: inspect.isclass(x) and issubclass(x, MasterHighlighter)):
classes.append(cls)
return classes
class MasterHighlighter(QtCore.QObject):
""" Master object containing shared highlighting rules.
"""
dirtied = QtCore.Signal()
# List of file extensions (without the starting '.') to register this
# highlighter for. The MasterHighlighter class is explicity set to [None]
# as the default highlighter when a matching file extension is not found.
extensions = [None]
# Character(s) to start a single-line comment, or None for no comment support.
comment = "#"
# Tuple of start and end strings for a multiline comment (e.g. ("--[[", "]]") for Lua),
# or None for no multiline comment support.
multilineComment = None
def __init__(self, parent, enableSyntaxHighlighting=False, programs=None):
""" Initialize the master highlighter, used once per language and shared among tabs.
:Parameters:
parent : `QtCore.QObject`
Can install to a `QTextEdit` or `QTextDocument` to apply highlighting.
enableSyntaxHighlighting : `bool`
Whether or not to enable syntax highlighting.
programs : `dict`
extension: program pairs of strings. This is used to contruct a syntax rule
to undo syntax highlighting on links so that we see their original colors.
"""
super(MasterHighlighter, self).__init__(parent)
# Highlighting rules. Rules farther down take priority.
self.highlightingRules = []
self.multilineRules = []
self.rules = []
# Match everything for clearing syntax highlighting.
self.blankRules = [createRule(".+")]
self.enableSyntax = None
self.findPhrase = None
# Undo syntax highlighting on at least some of our links so the assigned colors show.
self.ruleLink = createRule("*")
self.highlightingRules.append(self.ruleLink)
self.setLinkPattern(programs or {}, dirty=False)
# Some general single-line rules that apply to many file formats.
# Numeric literals
self.ruleNumber = [
r'\b[+-]?(?:[0-9]+[lL]?|0[xX][0-9A-Fa-f]+[lL]?|[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\b',
QtCore.Qt.darkBlue,
QtCore.Qt.cyan
]
# Double-quoted string, possibly containing escape sequences.
self.ruleDoubleQuote = [
r'"[^"\\]*(?:\\.[^"\\]*)*"',
QtCore.Qt.darkGreen,
QtGui.QColor(25, 255, 25)
]
# Single-quoted string, possibly containing escape sequences.
self.ruleSingleQuote = [
r"'[^'\\]*(?:\\.[^'\\]*)*'",
QtCore.Qt.darkGreen,
QtGui.QColor(25, 255, 25)
]
# Matches a comment from the starting point to the end of the line,
# if not part of a single- or double-quoted string.
if self.comment:
self.ruleComment = [
"^(?:[^\"']|\"[^\"]*\"|'[^']*')*(" + re.escape(self.comment) + ".*)$", # TODO: This should probably be language-specific instead of assumed for all.
QtCore.Qt.gray,
QtCore.Qt.gray,
None, # Not bold
True # Italic
]
# Create the rules specific to this syntax.
self.createRules()
# If createRules didn't place the link rule in a specific place, put it at the end.
if self.ruleLink not in self.highlightingRules:
self.highlightingRules.append(self.ruleLink)
self.setSyntaxHighlighting(enableSyntaxHighlighting)
def getRules(self):
""" Syntax rules specific to this highlighter class.
"""
# Operators.
return [
[ # Operators
r'[\-+*/%=!<>&|^~]',
QtCore.Qt.red,
QtGui.QColor("#F33")
],
self.ruleNumber,
self.ruleDoubleQuote,
self.ruleSingleQuote,
self.ruleLink, # Undo syntax highlighting on at least some of our links so the assigned colors show.
self.ruleComment
]
def createRules(self):
for r in self.getRules():
self.highlightingRules.append(createRule(*r) if type(r) is list else r)
# Multi-line comment.
if self.multilineComment:
self.multilineRules.append(createMultilineRule(
# Make sure the start of the comment isn't inside a single- or double-quoted string.
# TODO: This should probably be language-specific instead of assumed for all.
"^(?:[^\"']|\"[^\"]*\"|'[^']*')*(" + re.escape(self.multilineComment[0]) + ")",
re.escape(self.multilineComment[1]),
QtCore.Qt.gray,
italic=True))
def dirty(self):
""" Let highlighters that subscribe to this know a rule has changed.
"""
self.dirtied.emit()
def setLinkPattern(self, programs, dirty=True):
""" Set the rules to search for files based on file extensions, quotes, etc.
:Parameters:
programs : `dict`
extension: program pairs of strings.
dirty : `bool`
If we should trigger a rehighlight or not.
"""
# This is slightly different than the main program's RegEx because Qt doesn't support all the same things.
# TODO: Not allowing a backslash here might break Windows file paths if/when we try to support that.
self.ruleLink[0].setPattern(r'(?:[^\'"@()\t\n\r\f\v\\]*\.)(?:' + '|'.join(programs.keys()) + r')(?=(?:[\'")@]|\\\"))')
if dirty:
self.dirty()
def setSyntaxHighlighting(self, enable, force=True):
""" Enable/Disable syntax highlighting.
If enabling, dirties the state of this highlighter so highlighting runs again.
:Parameters:
enable : `bool`
Whether or not to enable syntax highlighting.
force : `bool`
Force re-enabling syntax highlighting even if it was already enabled.
Allows force rehighlighting even if nothing has really changed.
"""
if force or enable != self.enableSyntax:
self.enableSyntax = enable
self.rules = self.highlightingRules if enable else self.blankRules
self.dirty()
class Highlighter(QtGui.QSyntaxHighlighter):
masterClass = MasterHighlighter
def __init__(self, parent=None, master=None):
""" Syntax highlighter for an individual document in the app.
:Parameters:
parent : `QtCore.QObject`
Can install to a `QTextEdit` or `QTextDocument` to apply highlighting.
master : `MasterHighlighter` | None
Master object containing shared highlighting rules.
"""
super(Highlighter, self).__init__(parent)
self.master = master or self.masterClass(self)
self.findPhrase = None
self.dirty = False
# Connect this directly to self.rehighlight if we can ever manage to thread or speed that up.
self.master.dirtied.connect(self.setDirty)
def isDirty(self):
return self.dirty
def setDirty(self):
self.dirty = True
def highlightBlock(self, text):
""" Override this method only if needed for a specific language. """
# Really long lines like timeSamples in Crate files don't play nicely with RegEx.
# Skip them for now.
if len(text) > LINE_CHAR_LIMIT:
# TODO: Do we need to reset the block state or anything else here?
return
# Reduce name lookups for speed, since this is one of the slowest parts of the app.
setFormat = self.setFormat
currentBlockState = self.currentBlockState
setCurrentBlockState = self.setCurrentBlockState
previousBlockState = self.previousBlockState
for pattern, frmt in self.master.rules:
i = pattern.indexIn(text)
while i >= 0:
# If we have a grouped match, only highlight that first group and not the chars before it.
pos1 = pattern.pos(1)
if pos1 != -1:
length = pattern.matchedLength() - (pos1 - i)
i = pos1
else:
length = pattern.matchedLength()
setFormat(i, length, frmt)
i = pattern.indexIn(text, i + length)
setCurrentBlockState(0)
for state, (startExpr, endExpr, frmt) in enumerate(self.master.multilineRules, 1):
if previousBlockState() == state:
# We're already inside a match for this rule. See if there's an ending match.
startIndex = 0
add = 0
else:
# Look for the start of the expression.
startIndex = startExpr.indexIn(text)
# If we have a grouped match, only highlight that first group and not the chars before it.
pos1 = startExpr.pos(1)
if pos1 != -1:
add = startExpr.matchedLength() - (pos1 - startIndex)
startIndex = pos1
else:
add = startExpr.matchedLength()
# If we're inside the match, look for the end expression.
while startIndex >= 0:
endIndex = endExpr.indexIn(text, startIndex + add)
if endIndex >= add:
# We found the end of the multiline rule.
length = endIndex - startIndex + add + endExpr.matchedLength()
# Since we're at the end of this rule, reset the state so other multiline rules can try to match.
setCurrentBlockState(0)
else:
# Still inside the multiline rule.
length = len(text) - startIndex + add
setCurrentBlockState(state)
# Highlight the portion of this line that's inside the multiline rule.
# TODO: This doesn't actually ensure we hit the closing expression before highlighting.
setFormat(startIndex, length, frmt)
# Look for the next match.
startIndex = startExpr.indexIn(text, startIndex + length)
pos1 = startExpr.pos(1)
if pos1 != -1:
add = startExpr.matchedLength() - (pos1 - startIndex)
startIndex = pos1
else:
add = startExpr.matchedLength()
if currentBlockState() == state:
break
self.dirty = False