1
"""Browse the tokens in a GNU idutils ID file, and use it to navigate within a codebase.
3
The necessary parts of the ID file are held in memory to allow sufficient performance
4
for token completion, when looking up the usage of a token, or jumping to the
5
definition. etags(1) is used to locate the definition.
8
# Copyright 2012, 2013, Shaheed Haque <srhaque@theiet.org>.
10
# This program is free software; you can redistribute it and/or
11
# modify it under the terms of the GNU General Public License as
12
# published by the Free Software Foundation; either version 2 of
13
# the License or (at your option) version 3 or any later version
14
# accepted by the membership of KDE e.V. (or its successor approved
15
# by the membership of KDE e.V.), which shall act as a proxy
16
# defined in Section 14 of version 3 of the license.
18
# This program is distributed in the hope that it will be useful,
19
# but WITHOUT ANY WARRANTY; without even the implied warranty of
20
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21
# GNU General Public License for more details.
23
# You should have received a copy of the GNU General Public License
24
# along with this program. If not, see <http://www.gnu.org/licenses/>.
30
from PyQt4.QtCore import *
31
from PyQt4.QtGui import *
32
from PyKDE4.kdecore import *
33
from PyKDE4.kdeui import *
34
from PyKDE4.ktexteditor import KTextEditor
36
from .idutils import Lookup
48
completionModel = None
51
if sys.version_info.major >= 3:
52
return _bytes.decode("utf-8")
54
return unicode(_bytes)
56
class ConfigWidget(QWidget):
57
"""Configuration widget for this plugin."""
58
def __init__(self, parent = None, name = None):
59
super(ConfigWidget, self).__init__(parent)
61
# Location of ID file.
65
# Completion string minimum size.
69
# Original file prefix.
73
# Replacement file prefix.
77
# Set up the user interface from Designer.
78
uic.loadUi(os.path.join(os.path.dirname(__file__), "config.ui"), self)
83
kate.configuration["idFile"] = self.idFile.text()
84
kate.configuration["keySize"] = self.keySize.value()
85
kate.configuration["useEtags"] = self.useEtags.isChecked()
86
kate.configuration["useSuffixes"] = self.useSuffixes.text()
87
kate.configuration["srcIn"] = self.srcIn.text()
88
kate.configuration["srcOut"] = self.srcOut.text()
89
kate.configuration.save()
93
if "idFile" in kate.configuration:
94
self.idFile.setText(kate.configuration["idFile"])
95
if "keySize" in kate.configuration:
96
self.keySize.setValue(kate.configuration["keySize"])
97
if "useEtags" in kate.configuration:
98
self.useEtags.setChecked(kate.configuration["useEtags"])
99
if "useSuffixes" in kate.configuration:
100
self.useSuffixes.setText(kate.configuration["useSuffixes"])
101
if "srcIn" in kate.configuration:
102
self.srcIn.setText(kate.configuration["srcIn"])
103
if "srcOut" in kate.configuration:
104
self.srcOut.setText(kate.configuration["srcOut"])
107
self.idFile.setText("/view/myview/vob/ID")
108
self.keySize.setValue(5)
109
self.useEtags.setChecked(True)
110
self.useSuffixes.setText(".h;.hxx")
111
self.srcIn.setText("/vob")
112
self.srcOut.setText("%{idPrefix}/vob")
114
class ConfigPage(kate.Kate.PluginConfigPage, QWidget):
115
"""Kate configuration page for this plugin."""
116
def __init__(self, parent = None, name = None):
117
super(ConfigPage, self).__init__(parent, name)
118
self.widget = ConfigWidget(parent)
120
lo.addWidget(self.widget)
129
self.widget.defaults()
132
class ConfigDialog(KDialog):
133
"""Standalong configuration dialog for this plugin."""
134
def __init__(self, parent = None):
135
super(ConfigDialog, self).__init__(parent)
136
self.widget = ConfigWidget(self)
137
self.setMainWidget(self.widget)
138
self.setButtons(KDialog.ButtonCode(KDialog.Default | KDialog.Reset | KDialog.Ok | KDialog.Cancel))
139
self.applyClicked.connect(self.apply)
140
self.resetClicked.connect(self.reset)
141
self.defaultClicked.connect(self.defaults)
142
self.resize(600, 200)
154
self.widget.defaults()
157
"""Return the transformed file name."""
158
transformationKey = kate.configuration["srcIn"]
159
if len(transformationKey):
161
# A transformation of the file name is requested.
164
left, right = file.split(transformationKey, 1)
167
# No transformation is applicable.
170
percentI = kate.configuration["srcOut"].find("%{idPrefix}")
173
insertLeft, discard = kate.configuration["idFile"].split(transformationKey, 1)
176
# No transformation is possible.
179
discard, insertRight = kate.configuration["srcOut"].split("%{idPrefix}", 1)
180
file = insertLeft + insertRight + right
182
file = kate.configuration["srcOut"] + right
185
class HistoryModel(QStandardItemModel):
186
"""Support the display of a stack of navigation points.
187
Each visible row comprises { basename(fileName);line, text }.
188
On column 0, we show a tooltip with the full fileName, and any icon.
189
On column 1, we store line and column.
194
super(HistoryModel, self).__init__()
195
self.setHorizontalHeaderLabels((i18n("File"), i18n("Match")))
197
def add(self, fileName, icon, text, line, column, fileAndLine):
198
"""Add a new entry to the top of the stack."""
200
# Ignore if the top of the stack has an identical entry.
202
column0 = self.invisibleRootItem().child(0, 0)
204
if fileName == column0.data(Qt.ToolTipRole):
205
column1 = self.invisibleRootItem().child(0, 1)
206
if line == column1.data(Qt.UserRole + 1):
207
return column0.index()
208
column0 = QStandardItem(fileAndLine)
210
column0.setIcon(KIcon(icon))
211
column0.setData(fileName, Qt.ToolTipRole)
212
column1 = QStandardItem(text)
213
column1.setData(line, Qt.UserRole + 1)
214
column1.setData(column, Qt.UserRole + 2)
215
resultRow = (column0, column1)
216
self.invisibleRootItem().insertRow(0, resultRow)
217
if self.rowCount() > 64:
219
return resultRow[0].index()
221
def read(self, index):
224
column0 = index.sibling(row, 0)
225
fileAndLine = column0.data()
226
fileName = column0.data(Qt.ToolTipRole)
227
icon = column0.data(Qt.DecorationRole)
228
column1 = index.sibling(row, 1)
229
text = column1.data()
230
line = column1.data(Qt.UserRole + 1)
231
column = column1.data(Qt.UserRole + 2)
232
return (fileName, icon, text, line, column, fileAndLine)
234
class MatchesModel(HistoryModel):
235
"""Support the display of a list of entries matching a token.
236
The display matches HistoryModel, but is a list not a stack.
239
_boredomInterval = 20
241
def __init__(self, dataSource):
244
@param dataSource an instance of a Lookup().
246
super(MatchesModel, self).__init__()
247
self.dataSource = dataSource
249
def _etagSearch(self, token, fileName):
250
"""Use etags to find any definition in this file.
252
Look for [ 0x7f, token, 0x1 ].
254
if not kate.configuration["useEtags"]:
256
etagsCmd = ["etags", "-o", "-", fileName]
257
etagBytes = subprocess.check_output(etagsCmd, stderr = subprocess.STDOUT)
258
tokenBytes = bytearray(token.encode("utf-8"))
259
tokenBytes.insert(0, 0x7f)
260
tokenBytes.append(0x1)
261
etagDefinition = etagBytes.find(tokenBytes)
262
if etagDefinition > -1:
264
# The line number follows.
266
lineNumberStart = etagDefinition + len(tokenBytes)
267
lineNumberEnd = toStr(etagBytes).find(",", lineNumberStart)
268
return int(etagBytes[lineNumberStart:lineNumberEnd]) - 1
269
if etagBytes.startswith(bytearray(etagsCmd[0], "latin-1")):
271
# An error message was printed starting with "etags".
273
raise IOError(toStr(etagBytes))
276
def _scanFile(self, regexp, filterRe, token, fileName, isDeclaration):
277
"""Scan a file looking for interesting hits. Return the QModelIndex of the last of any definitions we find."""
278
definitionIndex = None
283
definitionLine = self._etagSearch(token, fileName)
285
# Question: what encoding is this file? TODO A better approach
288
for text in codecs.open(fileName, encoding="latin-1"):
289
match = regexp.search(text)
290
if match and filterRe:
291
match = filterRe.search(text)
294
column = match.start()
295
if line == definitionLine:
297
# Mark the line and the file as being a definition.
299
definitionIndex = self.add(fileName, "go-jump-definition", text[:-1], line, column)
301
self.add(fileName, "text-x-chdr", text[:-1], line, column)
303
self.add(fileName, None, text[:-1], line, column)
305
# FF and VT are line endings it seems...
307
if text[-1] != '\f' and text[-1] != '\v':
311
# This was in the index, but we found no hits. Assuming the file
312
# content has changed, we still permit navigation to the top of
315
self.add(fileName, "task-reject", "", 0, 0)
317
self.add(fileName, "face-sad", str(e), None, None)
318
return definitionIndex
320
def add(self, fileName, icon, text, line, column):
321
"""Append a new entry."""
323
column0 = QStandardItem("{}:{}".format(QFileInfo(fileName).fileName(), line + 1))
325
column0 = QStandardItem(QFileInfo(fileName).fileName())
327
column0.setIcon(KIcon(icon))
328
column0.setData(fileName, Qt.ToolTipRole)
329
column1 = QStandardItem(text)
330
column1.setData(line, Qt.UserRole + 1)
331
column1.setData(column, Qt.UserRole + 2)
332
resultRow = (column0, column1)
333
self.invisibleRootItem().appendRow(resultRow)
334
return resultRow[0].index()
336
def literalTokenSearch(self, parent, token, filter):
337
"""Add the entries which match the token to the matches, and return the QModelIndex of the last of any definitions we find.
339
Entries are grouped under the file in which the hits are searched. Each
340
entry shows the matched text, the line and column of the match. If so
341
enabled, entries which are defintions according to etags are highlighted.
343
If the output takes a long time to generate, the user is given options
344
to continue or abort.
347
self.definitionIndex = None
349
tokenFlags, hitCount, files = self.dataSource.literalSearch(token)
353
# Compile the REs we need.
356
filter = filter.replace("%{token}", token)
358
filterRe = re.compile(filter)
360
KMessageBox.error(parent.parent(), i18n("Filter '%1' is not a valid regular expression", filter), i18n("Invalid filter"))
364
regexp = re.compile("\\b" + token + "\\b")
365
if len(kate.configuration["useSuffixes"]) == 0:
368
declarationRe = kate.configuration["useSuffixes"].replace(";", "|")
369
declarationRe = "(" + declarationRe.replace(".", "\.") + ")$"
370
declarationRe = re.compile(declarationRe, re.IGNORECASE)
371
startBoredomQuery = time.time()
372
previousBoredomQuery = startBoredomQuery - MatchesModel._boredomInterval // 2
374
# For each file, list the lines where a match is found.
376
definitionIndex = None
378
for fileName, fileFlags in files:
379
fileName = transform(fileName)
380
isDeclaration = declarationRe and declarationRe.search(fileName)
382
# Update the definitionIndex when we get a good one.
384
newDefinitionIndex = self._scanFile(regexp, filterRe, token, fileName, isDeclaration)
385
if newDefinitionIndex:
386
definitionIndex = newDefinitionIndex
389
# Time to query the user's boredom level?
391
if time.time() - previousBoredomQuery > MatchesModel._boredomInterval:
392
r = KMessageBox.questionYesNoCancel(parent.parent(), i18n("Scanned %1 of %2 files in %3 seconds", filesListed, len(files), int(time.time() - startBoredomQuery)),
393
i18n("Scan more files?"), KGuiItem(i18n("All Files")), KGuiItem(i18n("More Files")), KStandardGuiItem.cancel())
394
if r == KMessageBox.Yes:
395
previousBoredomQuery = time.time() + 10 * MatchesModel._boredomInterval
396
elif r == KMessageBox.No:
397
previousBoredomQuery = time.time()
401
# Return the model index of the match.
403
return definitionIndex
405
class CompletionModel(KTextEditor.CodeCompletionModel):
406
"""Support Kate code completion.
408
def __init__(self, parent, dataSource):
411
@param parent Parent QObject.
412
@param dataSource An instance of a Lookup().
414
super(CompletionModel, self).__init__(parent)
415
self.dataSource = dataSource
416
self.completionObj = None
418
def completionInvoked(self, view, range, invocationType):
419
"""Find matches for the range given.
421
token = view.document().text(range)
422
if len(token) < kate.configuration["keySize"]:
424
# Don't try to match if the token is too short.
428
self.completionObj = list()
431
# Add the first item if we find one, then any other matches.
434
lastOffset, lastName = self.dataSource.prefixSearchFirst(lastToken)
436
self.completionObj.append(lastName)
437
lastOffset, lastName = self.dataSource.prefixSearchNext(lastOffset, lastToken)
440
self.setRowCount(len(self.completionObj))
442
def columnCount(self, index):
443
return KTextEditor.CodeCompletionModel.Name
445
def data(self, index, role):
446
"""Return the data defining the item.
448
column = index.column()
449
if column == KTextEditor.CodeCompletionModel.Name:
450
if role == Qt.DisplayRole:
452
return self.completionObj[index.row()]
453
elif role == KTextEditor.CodeCompletionModel.HighlightingMethod:
454
# Default highlighting.
457
#print "data()", index.row(), "Name", role
460
#print "data()", index.row(), column, role
463
class SearchBar(QObject):
464
def __init__(self, dataSource):
465
super(SearchBar, self).__init__(None)
466
self.lastToken = None
467
self.lastOffset = None
469
self.gotSettings = False
470
self.dataSource = dataSource
471
self.toolView = kate.mainInterfaceWindow().createToolView("idutils_gid_plugin", kate.Kate.MainWindow.Bottom, SmallIcon("edit-find"), i18n("gid Search"))
472
# By default, the toolview has box layout, which is not easy to delete.
473
# For now, just add an extra widget.
474
top = QWidget(self.toolView)
476
# Set up the user interface from Designer.
477
uic.loadUi(os.path.join(os.path.dirname(__file__), "tool.ui"), top)
478
self.token = top.token
479
self.filter = top.filter
480
self.settings = top.settings
482
self.matchesModel = MatchesModel(self.dataSource)
483
self.matchesWidget = top.matches
484
self.matchesWidget.verticalHeader().setDefaultSectionSize(20)
485
self.matchesWidget.setModel(self.matchesModel)
486
self.matchesWidget.setColumnWidth(0, 200)
487
self.matchesWidget.setColumnWidth(1, 400)
488
self.matchesWidget.activated.connect(self.navigateToMatch)
490
self.historyModel = HistoryModel()
491
self.historyWidget = top.history
492
self.historyWidget.verticalHeader().setDefaultSectionSize(20)
493
self.historyWidget.setModel(self.historyModel)
494
self.historyWidget.setColumnWidth(0, 200)
495
self.historyWidget.setColumnWidth(1, 400)
496
self.historyWidget.activated.connect(self.navigateToHistory)
498
self.token.setCompletionMode(KGlobalSettings.CompletionPopupAuto)
499
self.token.completion.connect(self._findCompletion)
500
self.token.completionObject().clear();
501
self.token.returnPressed.connect(self.literalSearch)
502
self.filter.returnPressed.connect(self.literalSearch)
503
self.settings.clicked.connect(self.getSettings)
506
"""Plugins that use a toolview need to delete it for reloading to work."""
507
assert(self.toolView is not None)
508
mw = kate.mainInterfaceWindow()
511
mw.destroyToolView(self.toolView)
515
def literalSearch(self):
516
"""Lookup a single token and return the modelIndex of any definition."""
517
definitionIndex = self.matchesModel.literalTokenSearch(self.toolView, self.token.currentText(), self.filter.currentText())
518
self.matchesWidget.resizeColumnsToContents()
521
# Set the navigation starting point to (the last of) any
522
# definition we found.
524
self.matchesWidget.setCurrentIndex(definitionIndex)
525
return definitionIndex
527
def _findCompletion(self, token):
528
"""Fill the completion object with potential matches."""
529
completionObj = self.token.completionObject()
530
completionObj.clear()
531
if len(token) < kate.configuration["keySize"]:
533
# Don't try to match if the token is too short.
538
# Add the first item if we find one, then any other matches.
541
lastOffset, lastName = self.dataSource.prefixSearchFirst(lastToken)
543
completionObj.addItem(lastName)
544
lastOffset, lastName = self.dataSource.prefixSearchNext(lastOffset, lastToken)
548
@pyqtSlot("QModelIndex &")
549
def navigateToMatch(self, index):
550
"""Jump to the selected entry."""
551
(fileName, icon, text, nextLine, column, fileAndLine) = self.matchesModel.read(index)
555
# Add a history record for the current point.
557
cursor = kate.activeView().cursorPosition()
558
currentLine = cursor.line()
559
document = kate.activeDocument()
560
self.historyModel.add(document.url().toLocalFile(), "arrow-right", document.line(currentLine), currentLine, cursor.column(), "{}:{}".format(document.documentName(), currentLine + 1))
562
# Navigate to the point in the file.
564
document = kate.documentManager.openUrl(KUrl.fromPath(fileName))
565
kate.mainInterfaceWindow().activateView(document)
566
point = KTextEditor.Cursor(nextLine, column)
567
kate.activeView().setCursorPosition(point)
569
# Add this new point to the history.
571
self.historyModel.add(fileName, icon, text, nextLine, column, fileAndLine)
572
self.historyWidget.resizeColumnsToContents()
574
@pyqtSlot("QModelIndex &")
575
def navigateToHistory(self, index):
576
"""Jump to the selected entry."""
577
(fileName, icon, text, line, column, fileAndLine) = self.historyModel.read(index)
579
# Navigate to the original point in the file.
581
document = kate.documentManager.openUrl(KUrl.fromPath(fileName))
582
kate.mainInterfaceWindow().activateView(document)
583
point = KTextEditor.Cursor(line, column)
584
kate.activeView().setCursorPosition(point)
587
def getSettings(self):
588
"""Show the settings dialog.
589
Establish our configuration. Loop until we have a viable settings, or
594
while not fileSet or not transformOK:
597
dialog = ConfigDialog(kate.mainWindow().centralWidget())
598
status = dialog.exec_()
599
if status == QDialog.Rejected:
601
dialog.widget.apply()
603
# Check the save file name.
607
# Only re-read the file if it has changed.
609
if not searchBar.dataSource.file or (searchBar.dataSource.file.name != kate.configuration["idFile"]):
610
searchBar.dataSource.setFile(kate.configuration["idFile"])
612
except IOError as detail:
613
KMessageBox.error(self.parent(), toStr(detail), i18n("ID database error"))
615
# Check the transformation settings.
618
transformationKey = kate.configuration["srcIn"]
619
percentI = kate.configuration["srcOut"].find("%i")
620
if len(transformationKey) and (percentI > -1):
622
# A transformation of the file name is with the output
623
# using the %i placeholder. Ensure the idFile is usable for
626
insertLeft, discard = kate.configuration["idFile"].split(transformationKey, 1)
628
except ValueError as detail:
629
# xgettext: no-python-format
630
KMessageBox.error(self.parent(), i18n("'%1' does not contain '%2'", kate.configuration["idFile"], transformationKey), i18n("Cannot use %i"))
631
return fileSet and transformOK
634
if not self.gotSettings:
635
self.gotSettings = self.getSettings()
637
kate.mainInterfaceWindow().showToolView(self.toolView)
638
self.token.setFocus(Qt.PopupFocusReason)
639
return self.gotSettings
642
kate.mainInterfaceWindow().hideToolView(self.toolView)
644
def wordAtCursorPosition(line, cursor):
645
''' Get the word under the active view's cursor in the given document.'''
646
# Better to use word boundaries than to hardcode valid letters because
647
# expansions should be able to be in any unicode character.
648
wordBoundary = re.compile("\W", re.UNICODE)
649
start = end = cursor.column()
650
if start == len(line) or wordBoundary.match(line[start]):
652
while start >= 0 and not wordBoundary.match(line[start]):
655
while end < len(line) and not wordBoundary.match(line[end]):
659
def wordAtCursor(document, view):
660
''' Get the word under the active view's cursor in the given document.
661
Stolen from the expand plugin!'''
662
cursor = view.cursorPosition()
663
line = document.line(cursor.line())
664
start, end = wordAtCursorPosition(line, cursor)
665
return line[start:end]
669
"""Browse the tokens in the ID file."""
670
# Make all our config is initialised.
671
ConfigWidget().apply()
674
if searchBar is None:
675
idDatabase = Lookup()
676
searchBar = SearchBar(idDatabase)
677
global completionModel
678
if completionModel is None:
679
completionModel = CompletionModel(kate.mainWindow(), idDatabase)
681
return searchBar.show()
685
"""Lookup the currently selected token.
686
Find the token, filter the results.
690
if kate.activeView().selection():
691
selectedText = kate.activeView().selectionText()
693
selectedText = wordAtCursor(kate.activeDocument(), kate.activeView())
694
searchBar.token.insertItem(0, selectedText)
695
searchBar.token.setCurrentIndex(1)
696
searchBar.token.setEditText(selectedText)
697
return searchBar.literalSearch()
701
def gotoDefinition():
702
"""Go to the definition of the currently selected token.
703
Find the token, search for definitions using etags, jump to the definition.
706
definitionIndex = lookup()
708
searchBar.navigateToMatch(definitionIndex)
711
i18nc("@item:inmenu", "GNU idutils")
712
, i18nc("@title:group", "<command section='1'>gid</command> token Lookup and Navigation")
715
def configPage(parent = None, name = None):
716
return ConfigPage(parent, name)
720
"""Plugins that use a toolview need to delete it for reloading to work."""
725
global completionModel
728
#completionModel = None
732
''' Calls the function when the view changes. To access the new active view,
733
use kate.activeView() '''
734
global completionModel
736
view = kate.activeView()
738
completionInterface = view.codeCompletionInterface()
739
completionInterface.registerCompletionModel(completionModel)