1 /***************************************************************************
2 pluginKatexmltools.cpp
3
4 List elements, attributes, attribute values and entities allowed by DTD.
5 Needs a DTD in XML format ( as produced by dtdparse ) for most features.
6
7 copyright : ( C ) 2001-2002 by Daniel Naber
8 email : daniel.naber@t-online.de
9
10 SPDX-FileCopyrightText: 2005 Anders Lund <anders@alweb.dk>
11
12 KDE SC 4 version (C) 2010 Tomas Trnka <tomastrnka@gmx.com>
13 ***************************************************************************/
14
15 /***************************************************************************
16 This program is free software; you can redistribute it and/or
17 modify it under the terms of the GNU General Public License
18 as published by the Free Software Foundation; either version 2
19 of the License, or ( at your option ) any later version.
20
21 This program is distributed in the hope that it will be useful,
22 but WITHOUT ANY WARRANTY; without even the implied warranty of
23 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24 GNU General Public License for more details.
25
26 You should have received a copy of the GNU General Public License
27 along with this program; if not, write to the Free Software
28 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
29 ***************************************************************************/
30
31 /*
32 README:
33 The basic idea is this: certain keyEvents(), namely [<&" ], trigger a completion box.
34 This is intended as a help for editing. There are some cases where the XML
35 spec is not followed, e.g. one can add the same attribute twice to an element.
36 Also see the user documentation. If backspace is pressed after a completion popup
37 was closed, the popup will re-open. This way typos can be corrected and the popup
38 will reappear, which is quite comfortable.
39
40 FIXME:
41 -( docbook ) <author lang="">: insert space between the quotes, press "de" and return -> only "d" inserted
42 -The "Insert Element" dialog isn't case insensitive, but it should be
43 -See the "fixme"'s in the code
44
45 TODO:
46 -check for mem leaks
47 -add "Go to opening/parent tag"?
48 -check doctype to get top-level element
49 -can undo behaviour be improved?, e.g. the plugins internal deletions of text
50 don't have to be an extra step
51 -don't offer entities if inside tag but outside attribute value
52
53 -Support for more than one namespace at the same time ( e.g. XSLT + XSL-FO )?
54 =>This could also be handled in the XSLT DTD fragment, as described in the XSLT 1.0 spec,
55 but then at <xsl:template match="/"><html> it will only show you HTML elements!
56 =>So better "Assign meta DTD" and "Add meta DTD", the latter will expand the current meta DTD
57 -Option to insert empty element in <empty/> form
58 -Show expanded entities with QChar::QChar( int rc ) + unicode font
59 -Don't ignore entities defined in the document's prologue
60 -Only offer 'valid' elements, i.e. don't take the elements as a set but check
61 if the DTD is matched ( order, number of occurrences, ... )
62
63 -Maybe only read the meta DTD file once, then store the resulting QMap on disk ( using QDataStream )?
64 We'll then have to compare timeOf_cacheFile <-> timeOf_metaDtd.
65 -Try to use libxml
66 */
67
68 #include "plugin_katexmltools.h"
69
70 #include <QAction>
71 #include <QComboBox>
72 #include <QFile>
73 #include <QFileDialog>
74 #include <QGuiApplication>
75 #include <QLabel>
76 #include <QLineEdit>
77 #include <QPushButton>
78 #include <QRegularExpression>
79 #include <QStandardPaths>
80 #include <QUrl>
81 #include <QVBoxLayout>
82
83 #include <ktexteditor/editor.h>
84
85 #include <KActionCollection>
86 #include <KHistoryComboBox>
87 #include <KLocalizedString>
88 #include <KMessageBox>
89 #include <KPluginFactory>
90 #include <KXMLGUIClient>
91 #include <kio/job.h>
92 #include <kio/jobuidelegate.h>
93 #include <kxmlguifactory.h>
94
95 K_PLUGIN_FACTORY_WITH_JSON(PluginKateXMLToolsFactory, "katexmltools.json", registerPlugin<PluginKateXMLTools>();)
96
PluginKateXMLTools(QObject * const parent,const QVariantList &)97 PluginKateXMLTools::PluginKateXMLTools(QObject *const parent, const QVariantList &)
98 : KTextEditor::Plugin(parent)
99 {
100 }
101
~PluginKateXMLTools()102 PluginKateXMLTools::~PluginKateXMLTools()
103 {
104 }
105
createView(KTextEditor::MainWindow * mainWindow)106 QObject *PluginKateXMLTools::createView(KTextEditor::MainWindow *mainWindow)
107 {
108 return new PluginKateXMLToolsView(mainWindow);
109 }
110
PluginKateXMLToolsView(KTextEditor::MainWindow * mainWin)111 PluginKateXMLToolsView::PluginKateXMLToolsView(KTextEditor::MainWindow *mainWin)
112 : QObject(mainWin)
113 , m_mainWindow(mainWin)
114 , m_model(this)
115 {
116 // qDebug() << "PluginKateXMLTools constructor called";
117
118 KXMLGUIClient::setComponentName(QStringLiteral("katexmltools"), i18n("Kate XML Tools"));
119 setXMLFile(QStringLiteral("ui.rc"));
120
121 QAction *actionInsert = new QAction(i18n("&Insert Element..."), this);
122 connect(actionInsert, &QAction::triggered, &m_model, &PluginKateXMLToolsCompletionModel::slotInsertElement);
123 actionCollection()->addAction(QStringLiteral("xml_tool_insert_element"), actionInsert);
124 actionCollection()->setDefaultShortcut(actionInsert, Qt::CTRL | Qt::Key_Return);
125
126 QAction *actionClose = new QAction(i18n("&Close Element"), this);
127 connect(actionClose, &QAction::triggered, &m_model, &PluginKateXMLToolsCompletionModel::slotCloseElement);
128 actionCollection()->addAction(QStringLiteral("xml_tool_close_element"), actionClose);
129 actionCollection()->setDefaultShortcut(actionClose, Qt::CTRL | Qt::Key_Less);
130
131 QAction *actionAssignDTD = new QAction(i18n("Assign Meta &DTD..."), this);
132 connect(actionAssignDTD, &QAction::triggered, &m_model, &PluginKateXMLToolsCompletionModel::getDTD);
133 actionCollection()->addAction(QStringLiteral("xml_tool_assign"), actionAssignDTD);
134
135 mainWin->guiFactory()->addClient(this);
136
137 connect(KTextEditor::Editor::instance()->application(),
138 &KTextEditor::Application::documentDeleted,
139 &m_model,
140 &PluginKateXMLToolsCompletionModel::slotDocumentDeleted);
141 }
142
~PluginKateXMLToolsView()143 PluginKateXMLToolsView::~PluginKateXMLToolsView()
144 {
145 m_mainWindow->guiFactory()->removeClient(this);
146
147 // qDebug() << "xml tools destructor 1...";
148 // TODO: unregister the model
149 }
150
PluginKateXMLToolsCompletionModel(QObject * const parent)151 PluginKateXMLToolsCompletionModel::PluginKateXMLToolsCompletionModel(QObject *const parent)
152 : CodeCompletionModel(parent)
153 , m_viewToAssignTo(nullptr)
154 , m_mode(none)
155 , m_correctPos(0)
156 {
157 }
158
~PluginKateXMLToolsCompletionModel()159 PluginKateXMLToolsCompletionModel::~PluginKateXMLToolsCompletionModel()
160 {
161 qDeleteAll(m_dtds);
162 m_dtds.clear();
163 }
164
slotDocumentDeleted(KTextEditor::Document * doc)165 void PluginKateXMLToolsCompletionModel::slotDocumentDeleted(KTextEditor::Document *doc)
166 {
167 // Remove the document from m_DTDs, and also delete the PseudoDTD
168 // if it becomes unused.
169 if (m_docDtds.contains(doc)) {
170 qDebug() << "XMLTools:slotDocumentDeleted: documents: " << m_docDtds.count() << ", DTDs: " << m_dtds.count();
171 PseudoDTD *dtd = m_docDtds.take(doc);
172
173 if (m_docDtds.key(dtd)) {
174 return;
175 }
176
177 QHash<QString, PseudoDTD *>::iterator it;
178 for (it = m_dtds.begin(); it != m_dtds.end(); ++it) {
179 if (it.value() == dtd) {
180 m_dtds.erase(it);
181 delete dtd;
182 return;
183 }
184 }
185 }
186 }
187
completionInvoked(KTextEditor::View * kv,const KTextEditor::Range & range,const InvocationType invocationType)188 void PluginKateXMLToolsCompletionModel::completionInvoked(KTextEditor::View *kv, const KTextEditor::Range &range, const InvocationType invocationType)
189 {
190 Q_UNUSED(range)
191 Q_UNUSED(invocationType)
192
193 qDebug() << "xml tools completionInvoked";
194
195 KTextEditor::Document *doc = kv->document();
196 if (!m_docDtds[doc])
197 // no meta DTD assigned yet
198 {
199 return;
200 }
201
202 // debug to test speed:
203 // QTime t; t.start();
204
205 beginResetModel();
206 m_allowed.clear();
207
208 // get char on the left of the cursor:
209 KTextEditor::Cursor curpos = kv->cursorPosition();
210 uint line = curpos.line(), col = curpos.column();
211
212 QString lineStr = kv->document()->line(line);
213 QString leftCh = lineStr.mid(col - 1, 1);
214 QString secondLeftCh = lineStr.mid(col - 2, 1);
215
216 if (leftCh == QLatin1String("&")) {
217 qDebug() << "Getting entities";
218 m_allowed = m_docDtds[doc]->entities(QString());
219 m_mode = entities;
220 } else if (leftCh == QLatin1String("<")) {
221 qDebug() << "*outside tag -> get elements";
222 QString parentElement = getParentElement(*kv, 1);
223 qDebug() << "parent: " << parentElement;
224 m_allowed = m_docDtds[doc]->allowedElements(parentElement);
225 m_mode = elements;
226 } else if (leftCh == QLatin1String("/") && secondLeftCh == QLatin1String("<")) {
227 qDebug() << "*close parent element";
228 QString parentElement = getParentElement(*kv, 2);
229
230 if (!parentElement.isEmpty()) {
231 m_mode = closingtag;
232 m_allowed = QStringList(parentElement);
233 }
234 } else if (leftCh == QLatin1Char(' ') || (isQuote(leftCh) && secondLeftCh == QLatin1String("="))) {
235 // TODO: check secondLeftChar, too?! then you don't need to trigger
236 // with space and we yet save CPU power
237 QString currentElement = insideTag(*kv);
238 QString currentAttribute;
239 if (!currentElement.isEmpty()) {
240 currentAttribute = insideAttribute(*kv);
241 }
242
243 qDebug() << "Tag: " << currentElement;
244 qDebug() << "Attr: " << currentAttribute;
245
246 if (!currentElement.isEmpty() && !currentAttribute.isEmpty()) {
247 qDebug() << "*inside attribute -> get attribute values";
248 m_allowed = m_docDtds[doc]->attributeValues(currentElement, currentAttribute);
249 if (m_allowed.count() == 1
250 && (m_allowed[0] == QLatin1String("CDATA") || m_allowed[0] == QLatin1String("ID") || m_allowed[0] == QLatin1String("IDREF")
251 || m_allowed[0] == QLatin1String("IDREFS") || m_allowed[0] == QLatin1String("ENTITY") || m_allowed[0] == QLatin1String("ENTITIES")
252 || m_allowed[0] == QLatin1String("NMTOKEN") || m_allowed[0] == QLatin1String("NMTOKENS") || m_allowed[0] == QLatin1String("NAME"))) {
253 // these must not be taken literally, e.g. don't insert the string "CDATA"
254 m_allowed.clear();
255 } else {
256 m_mode = attributevalues;
257 }
258 } else if (!currentElement.isEmpty()) {
259 qDebug() << "*inside tag -> get attributes";
260 m_allowed = m_docDtds[doc]->allowedAttributes(currentElement);
261 m_mode = attributes;
262 }
263 }
264
265 // qDebug() << "time elapsed (ms): " << t.elapsed();
266 qDebug() << "Allowed strings: " << m_allowed.count();
267
268 if (m_allowed.count() >= 1 && m_allowed[0] != QLatin1String("__EMPTY")) {
269 m_allowed = sortQStringList(m_allowed);
270 }
271 setRowCount(m_allowed.count());
272 endResetModel();
273 }
274
columnCount(const QModelIndex &) const275 int PluginKateXMLToolsCompletionModel::columnCount(const QModelIndex &) const
276 {
277 return 1;
278 }
279
rowCount(const QModelIndex & parent) const280 int PluginKateXMLToolsCompletionModel::rowCount(const QModelIndex &parent) const
281 {
282 if (!m_allowed.isEmpty()) { // Is there smth to complete?
283 if (!parent.isValid()) { // Return the only one group node for root
284 return 1;
285 }
286 if (parent.internalId() == groupNode) { // Return available rows count for group level node
287 return m_allowed.size();
288 }
289 }
290 return 0;
291 }
292
parent(const QModelIndex & index) const293 QModelIndex PluginKateXMLToolsCompletionModel::parent(const QModelIndex &index) const
294 {
295 if (!index.isValid()) { // Is root/invalid index?
296 return QModelIndex(); // Nothing to return...
297 }
298 if (index.internalId() == groupNode) { // Return a root node for group
299 return QModelIndex();
300 }
301 // Otherwise, this is a leaf level, so return the only group as a parent
302 return createIndex(0, 0, groupNode);
303 }
304
index(const int row,const int column,const QModelIndex & parent) const305 QModelIndex PluginKateXMLToolsCompletionModel::index(const int row, const int column, const QModelIndex &parent) const
306 {
307 if (!parent.isValid()) {
308 // At 'top' level only 'header' present, so nothing else than row 0 can be here...
309 return row == 0 ? createIndex(row, column, groupNode) : QModelIndex();
310 }
311 if (parent.internalId() == groupNode) { // Is this a group node?
312 if (0 <= row && row < m_allowed.size()) { // Make sure to return only valid indices
313 return createIndex(row, column, nullptr); // Just return a leaf-level index
314 }
315 }
316 // Leaf node has no children... nothing to return
317 return QModelIndex();
318 }
319
data(const QModelIndex & index,int role) const320 QVariant PluginKateXMLToolsCompletionModel::data(const QModelIndex &index, int role) const
321 {
322 if (!index.isValid()) { // Nothing to do w/ invalid index
323 return QVariant();
324 }
325
326 if (index.internalId() == groupNode) { // Return group level node data
327 switch (role) {
328 case KTextEditor::CodeCompletionModel::GroupRole:
329 return QVariant(Qt::DisplayRole);
330 case Qt::DisplayRole:
331 return currentModeToString();
332 default:
333 break;
334 }
335 return QVariant(); // Nothing to return for other roles
336 }
337 switch (role) {
338 case Qt::DisplayRole:
339 switch (index.column()) {
340 case KTextEditor::CodeCompletionModel::Name:
341 return m_allowed.at(index.row());
342 default:
343 break;
344 }
345 default:
346 break;
347 }
348 return QVariant();
349 }
350
shouldStartCompletion(KTextEditor::View * view,const QString & insertedText,bool userInsertion,const KTextEditor::Cursor & position)351 bool PluginKateXMLToolsCompletionModel::shouldStartCompletion(KTextEditor::View *view,
352 const QString &insertedText,
353 bool userInsertion,
354 const KTextEditor::Cursor &position)
355 {
356 Q_UNUSED(view)
357 Q_UNUSED(userInsertion)
358 Q_UNUSED(position)
359 const QString triggerChars = QStringLiteral("&</ '\""); // these are subsequently handled by completionInvoked()
360
361 return triggerChars.contains(insertedText.right(1));
362 }
363
364 /**
365 * Load the meta DTD. In case of success set the 'ready'
366 * flag to true, to show that we're is ready to give hints about the DTD.
367 */
getDTD()368 void PluginKateXMLToolsCompletionModel::getDTD()
369 {
370 if (!KTextEditor::Editor::instance()->application()->activeMainWindow()) {
371 return;
372 }
373
374 KTextEditor::View *kv = KTextEditor::Editor::instance()->application()->activeMainWindow()->activeView();
375 if (!kv) {
376 qDebug() << "Warning: no KTextEditor::View";
377 return;
378 }
379
380 // ### replace this with something more sane
381 // Start where the supplied XML-DTDs are fed by default unless
382 // user changed directory last time:
383 QString defaultDir = QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("katexmltools")) + "/katexmltools/";
384 if (m_urlString.isNull()) {
385 m_urlString = defaultDir;
386 }
387
388 // Guess the meta DTD by looking at the doctype's public identifier.
389 // XML allows comments etc. before the doctype, so look further than
390 // just the first line.
391 // Example syntax:
392 // <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
393 uint checkMaxLines = 200;
394 QString documentStart = kv->document()->text(KTextEditor::Range(0, 0, checkMaxLines + 1, 0));
395 const QRegularExpression re(QStringLiteral("<!DOCTYPE\\s+\\b(\\w+)\\b\\s+PUBLIC\\s+[\"\']([^\"\']+?)[\"\']"), QRegularExpression::CaseInsensitiveOption);
396 const QRegularExpressionMatch match = re.match(documentStart);
397 QString filename;
398 QString doctype;
399 QString topElement;
400
401 if (match.hasMatch()) {
402 topElement = match.captured(1);
403 doctype = match.captured(2);
404 qDebug() << "Top element: " << topElement;
405 qDebug() << "Doctype match: " << doctype;
406 // XHTML:
407 if (doctype == QLatin1String("-//W3C//DTD XHTML 1.0 Transitional//EN")) {
408 filename = QStringLiteral("xhtml1-transitional.dtd.xml");
409 } else if (doctype == QLatin1String("-//W3C//DTD XHTML 1.0 Strict//EN")) {
410 filename = QStringLiteral("xhtml1-strict.dtd.xml");
411 } else if (doctype == QLatin1String("-//W3C//DTD XHTML 1.0 Frameset//EN")) {
412 filename = QStringLiteral("xhtml1-frameset.dtd.xml");
413 }
414 // HTML 4.0:
415 else if (doctype == QLatin1String("-//W3C//DTD HTML 4.01 Transitional//EN")) {
416 filename = QStringLiteral("html4-loose.dtd.xml");
417 } else if (doctype == QLatin1String("-//W3C//DTD HTML 4.01//EN")) {
418 filename = QStringLiteral("html4-strict.dtd.xml");
419 }
420 // KDE Docbook:
421 else if (doctype == QLatin1String("-//KDE//DTD DocBook XML V4.1.2-Based Variant V1.1//EN")) {
422 filename = QStringLiteral("kde-docbook.dtd.xml");
423 }
424 } else if (documentStart.indexOf(QLatin1String("<xsl:stylesheet")) != -1
425 && documentStart.indexOf(QLatin1String("xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"")) != -1) {
426 /* XSLT doesn't have a doctype/DTD. We look for an xsl:stylesheet tag instead.
427 Example:
428 <xsl:stylesheet version="1.0"
429 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
430 xmlns="http://www.w3.org/TR/xhtml1/strict">
431 */
432 filename = QStringLiteral("xslt-1.0.dtd.xml");
433 doctype = QStringLiteral("XSLT 1.0");
434 } else {
435 qDebug() << "No doctype found";
436 }
437
438 QUrl url;
439 if (filename.isEmpty()) {
440 // no meta dtd found for this file
441 url = QFileDialog::getOpenFileUrl(KTextEditor::Editor::instance()->application()->activeMainWindow()->window(),
442 i18n("Assign Meta DTD in XML Format"),
443 QUrl::fromLocalFile(m_urlString),
444 QStringLiteral("*.xml"));
445 } else {
446 url.setUrl(defaultDir + filename);
447 KMessageBox::information(nullptr,
448 i18n("The current file has been identified "
449 "as a document of type \"%1\". The meta DTD for this document type "
450 "will now be loaded.",
451 doctype),
452 i18n("Loading XML Meta DTD"),
453 QStringLiteral("DTDAssigned"));
454 }
455
456 if (url.isEmpty()) {
457 return;
458 }
459
460 m_urlString = url.url(); // remember directory for next time
461
462 if (m_dtds[m_urlString]) {
463 assignDTD(m_dtds[m_urlString], kv);
464 } else {
465 m_dtdString.clear();
466 m_viewToAssignTo = kv;
467
468 QGuiApplication::setOverrideCursor(Qt::WaitCursor);
469 KIO::TransferJob *job = KIO::get(url);
470 connect(job, &KIO::TransferJob::result, this, &PluginKateXMLToolsCompletionModel::slotFinished);
471 connect(job, &KIO::TransferJob::data, this, &PluginKateXMLToolsCompletionModel::slotData);
472 }
473 qDebug() << "XMLTools::getDTD: Documents: " << m_docDtds.count() << ", DTDs: " << m_dtds.count();
474 }
475
slotFinished(KJob * job)476 void PluginKateXMLToolsCompletionModel::slotFinished(KJob *job)
477 {
478 if (job->error()) {
479 // qDebug() << "XML Plugin error: DTD in XML format (" << filename << " ) could not be loaded";
480 static_cast<KIO::Job *>(job)->uiDelegate()->showErrorMessage();
481 } else if (static_cast<KIO::TransferJob *>(job)->isErrorPage()) {
482 // catch failed loading loading via http:
483 KMessageBox::error(nullptr,
484 i18n("The file '%1' could not be opened. "
485 "The server returned an error.",
486 m_urlString),
487 i18n("XML Plugin Error"));
488 } else {
489 PseudoDTD *dtd = new PseudoDTD();
490 dtd->analyzeDTD(m_urlString, m_dtdString);
491
492 m_dtds.insert(m_urlString, dtd);
493 assignDTD(dtd, m_viewToAssignTo);
494
495 // clean up a bit
496 m_viewToAssignTo = nullptr;
497 m_dtdString.clear();
498 }
499 QGuiApplication::restoreOverrideCursor();
500 }
501
slotData(KIO::Job *,const QByteArray & data)502 void PluginKateXMLToolsCompletionModel::slotData(KIO::Job *, const QByteArray &data)
503 {
504 m_dtdString += QString(data);
505 }
506
assignDTD(PseudoDTD * dtd,KTextEditor::View * view)507 void PluginKateXMLToolsCompletionModel::assignDTD(PseudoDTD *dtd, KTextEditor::View *view)
508 {
509 m_docDtds.insert(view->document(), dtd);
510
511 // TODO:perhaps for all views()?
512 KTextEditor::CodeCompletionInterface *cci = qobject_cast<KTextEditor::CodeCompletionInterface *>(view);
513
514 if (cci) {
515 cci->registerCompletionModel(this);
516 cci->setAutomaticInvocationEnabled(true);
517 qDebug() << "PluginKateXMLToolsView: completion model registered";
518 } else {
519 qWarning() << "PluginKateXMLToolsView: completion interface unavailable";
520 }
521 }
522
523 /**
524 * Offer a line edit with completion for possible elements at cursor position and insert the
525 * tag one chosen/entered by the user, plus its closing tag. If there's a text selection,
526 * add the markup around it.
527 */
slotInsertElement()528 void PluginKateXMLToolsCompletionModel::slotInsertElement()
529 {
530 if (!KTextEditor::Editor::instance()->application()->activeMainWindow()) {
531 return;
532 }
533
534 KTextEditor::View *kv = KTextEditor::Editor::instance()->application()->activeMainWindow()->activeView();
535 if (!kv) {
536 qDebug() << "Warning: no KTextEditor::View";
537 return;
538 }
539
540 KTextEditor::Document *doc = kv->document();
541 PseudoDTD *dtd = m_docDtds[doc];
542 QString parentElement = getParentElement(*kv, 0);
543 QStringList allowed;
544
545 if (dtd) {
546 allowed = dtd->allowedElements(parentElement);
547 }
548
549 QString text;
550 InsertElement dialog(allowed, kv);
551 if (dialog.exec() == QDialog::Accepted) {
552 text = dialog.text();
553 }
554
555 if (!text.isEmpty()) {
556 QStringList list = text.split(QChar(' '));
557 QString pre;
558 QString post;
559 // anders: use <tagname/> if the tag is required to be empty.
560 // In that case maybe we should not remove the selection? or overwrite it?
561 int adjust = 0; // how much to move cursor.
562 // if we know that we have attributes, it goes
563 // just after the tag name, otherwise between tags.
564 if (dtd && dtd->allowedAttributes(list[0]).count()) {
565 adjust++; // the ">"
566 }
567
568 if (dtd && dtd->allowedElements(list[0]).contains(QLatin1String("__EMPTY"))) {
569 pre = '<' + text + "/>";
570 if (adjust) {
571 adjust++; // for the "/"
572 }
573 } else {
574 pre = '<' + text + '>';
575 post = "</" + list[0] + '>';
576 }
577
578 QString marked;
579 if (!post.isEmpty()) {
580 marked = kv->selectionText();
581 }
582
583 KTextEditor::Document::EditingTransaction transaction(doc);
584
585 if (!marked.isEmpty()) {
586 kv->removeSelectionText();
587 }
588
589 // with the old selection now removed, curPos points to the start of pre
590 KTextEditor::Cursor curPos = kv->cursorPosition();
591 curPos.setColumn(curPos.column() + pre.length() - adjust);
592
593 kv->insertText(pre + marked + post);
594
595 kv->setCursorPosition(curPos);
596 }
597 }
598
599 /**
600 * Insert a closing tag for the nearest not-closed parent element.
601 */
slotCloseElement()602 void PluginKateXMLToolsCompletionModel::slotCloseElement()
603 {
604 if (!KTextEditor::Editor::instance()->application()->activeMainWindow()) {
605 return;
606 }
607
608 KTextEditor::View *kv = KTextEditor::Editor::instance()->application()->activeMainWindow()->activeView();
609 if (!kv) {
610 qDebug() << "Warning: no KTextEditor::View";
611 return;
612 }
613 QString parentElement = getParentElement(*kv, 0);
614
615 // qDebug() << "parentElement: '" << parentElement << "'";
616 QString closeTag = "</" + parentElement + '>';
617 if (!parentElement.isEmpty()) {
618 kv->insertText(closeTag);
619 }
620 }
621
622 // modify the completion string before it gets inserted
executeCompletionItem(KTextEditor::View * view,const KTextEditor::Range & word,const QModelIndex & index) const623 void PluginKateXMLToolsCompletionModel::executeCompletionItem(KTextEditor::View *view, const KTextEditor::Range &word, const QModelIndex &index) const
624 {
625 KTextEditor::Range toReplace = word;
626 KTextEditor::Document *document = view->document();
627
628 QString text = data(index.sibling(index.row(), Name), Qt::DisplayRole).toString();
629
630 qDebug() << "executeCompletionItem text: " << text;
631
632 int line, col;
633 view->cursorPosition().position(line, col);
634 QString lineStr = document->line(line);
635 QString rightCh = lineStr.mid(col, 1);
636
637 int posCorrection = 0; // where to move the cursor after completion ( >0 = move right )
638 if (m_mode == entities) {
639 text = text + ';';
640 }
641
642 else if (m_mode == attributes) {
643 text = text + "=\"\"";
644 posCorrection = -1;
645 if (!rightCh.isEmpty() && rightCh != QLatin1String(">") && rightCh != QLatin1String("/") && rightCh != QLatin1String(" ")) {
646 // TODO: other whitespaces
647 // add space in front of the next attribute
648 text = text + ' ';
649 posCorrection--;
650 }
651 }
652
653 else if (m_mode == attributevalues) {
654 // TODO: support more than one line
655 uint startAttValue = 0;
656 uint endAttValue = 0;
657
658 // find left quote:
659 for (startAttValue = col; startAttValue > 0; startAttValue--) {
660 QString ch = lineStr.mid(startAttValue - 1, 1);
661 if (isQuote(ch)) {
662 break;
663 }
664 }
665
666 // find right quote:
667 for (endAttValue = col; endAttValue <= static_cast<uint>(lineStr.length()); endAttValue++) {
668 QString ch = lineStr.mid(endAttValue - 1, 1);
669 if (isQuote(ch)) {
670 break;
671 }
672 }
673
674 // replace the current contents of the attribute
675 if (startAttValue < endAttValue) {
676 toReplace = KTextEditor::Range(line, startAttValue, line, endAttValue - 1);
677 }
678 }
679
680 else if (m_mode == elements) {
681 // anders: if the tag is marked EMPTY, insert in form <tagname/>
682 QString str;
683 bool isEmptyTag = m_docDtds[document]->allowedElements(text).contains(QLatin1String("__EMPTY"));
684 if (isEmptyTag) {
685 str = text + "/>";
686 } else {
687 str = text + "></" + text + '>';
688 }
689
690 // Place the cursor where it is most likely wanted:
691 // always inside the tag if the tag is empty AND the DTD indicates that there are attribs)
692 // outside for open tags, UNLESS there are mandatory attributes
693 if (m_docDtds[document]->requiredAttributes(text).count() || (isEmptyTag && m_docDtds[document]->allowedAttributes(text).count())) {
694 posCorrection = text.length() - str.length();
695 } else if (!isEmptyTag) {
696 posCorrection = text.length() - str.length() + 1;
697 }
698
699 text = str;
700 }
701
702 else if (m_mode == closingtag) {
703 text += '>';
704 }
705
706 document->replaceText(toReplace, text);
707
708 // move the cursor to desired position
709 KTextEditor::Cursor curPos = view->cursorPosition();
710 curPos.setColumn(curPos.column() + posCorrection);
711 view->setCursorPosition(curPos);
712 }
713
714 // ========================================================================
715 // Pseudo-XML stuff:
716
717 /**
718 * Check if cursor is inside a tag, that is
719 * if "<" occurs before ">" occurs ( on the left side of the cursor ).
720 * Return the tag name, return "" if we cursor is outside a tag.
721 */
insideTag(KTextEditor::View & kv)722 QString PluginKateXMLToolsCompletionModel::insideTag(KTextEditor::View &kv)
723 {
724 int line, col;
725 kv.cursorPosition().position(line, col);
726 int y = line; // another variable because uint <-> int
727
728 do {
729 QString lineStr = kv.document()->line(y);
730 for (uint x = col; x > 0; x--) {
731 QString ch = lineStr.mid(x - 1, 1);
732 if (ch == QLatin1String(">")) { // cursor is outside tag
733 return QString();
734 }
735
736 if (ch == QLatin1String("<")) {
737 QString tag;
738 // look for white space on the right to get the tag name
739 for (int z = x; z <= lineStr.length(); ++z) {
740 ch = lineStr.mid(z - 1, 1);
741 if (ch.at(0).isSpace() || ch == QLatin1String("/") || ch == QLatin1String(">")) {
742 return tag.right(tag.length() - 1);
743 }
744
745 if (z == lineStr.length()) {
746 tag += ch;
747 return tag.right(tag.length() - 1);
748 }
749
750 tag += ch;
751 }
752 }
753 }
754 y--;
755 col = kv.document()->line(y).length();
756 } while (y >= 0);
757
758 return QString();
759 }
760
761 /**
762 * Check if cursor is inside an attribute value, that is
763 * if '="' is on the left, and if it's nearer than "<" or ">".
764 *
765 * @Return the attribute name or "" if we're outside an attribute
766 * value.
767 *
768 * Note: only call when insideTag() == true.
769 * TODO: allow whitespace around "="
770 */
insideAttribute(KTextEditor::View & kv)771 QString PluginKateXMLToolsCompletionModel::insideAttribute(KTextEditor::View &kv)
772 {
773 int line, col;
774 kv.cursorPosition().position(line, col);
775 int y = line; // another variable because uint <-> int
776 uint x = 0;
777 QString lineStr;
778 QString ch;
779
780 do {
781 lineStr = kv.document()->line(y);
782 for (x = col; x > 0; x--) {
783 ch = lineStr.mid(x - 1, 1);
784 QString chLeft = lineStr.mid(x - 2, 1);
785 // TODO: allow whitespace
786 if (isQuote(ch) && chLeft == QLatin1String("=")) {
787 break;
788 } else if (isQuote(ch) && chLeft != QLatin1String("=")) {
789 return QString();
790 } else if (ch == QLatin1String("<") || ch == QLatin1String(">")) {
791 return QString();
792 }
793 }
794 y--;
795 col = kv.document()->line(y).length();
796 } while (!isQuote(ch));
797
798 // look for next white space on the left to get the tag name
799 QString attr;
800 for (int z = x; z >= 0; z--) {
801 ch = lineStr.mid(z - 1, 1);
802
803 if (ch.at(0).isSpace()) {
804 break;
805 }
806
807 if (z == 0) {
808 // start of line == whitespace
809 attr += ch;
810 break;
811 }
812
813 attr = ch + attr;
814 }
815
816 return attr.left(attr.length() - 2);
817 }
818
819 /**
820 * Find the parent element for the current cursor position. That is,
821 * go left and find the first opening element that's not closed yet,
822 * ignoring empty elements.
823 * Examples: If cursor is at "X", the correct parent element is "p":
824 * <p> <a x="xyz"> foo <i> test </i> bar </a> X
825 * <p> <a x="xyz"> foo bar </a> X
826 * <p> foo <img/> bar X
827 * <p> foo bar X
828 */
getParentElement(KTextEditor::View & kv,int skipCharacters)829 QString PluginKateXMLToolsCompletionModel::getParentElement(KTextEditor::View &kv, int skipCharacters)
830 {
831 enum { parsingText, parsingElement, parsingElementBoundary, parsingNonElement, parsingAttributeDquote, parsingAttributeSquote, parsingIgnore } parseState;
832 parseState = (skipCharacters > 0) ? parsingIgnore : parsingText;
833
834 int nestingLevel = 0;
835
836 int line, col;
837 kv.cursorPosition().position(line, col);
838 QString str = kv.document()->line(line);
839
840 while (true) {
841 // move left a character
842 if (!col--) {
843 do {
844 if (!line--) {
845 return QString(); // reached start of document
846 }
847 str = kv.document()->line(line);
848 col = str.length();
849 } while (!col);
850 --col;
851 }
852
853 ushort ch = str.at(col).unicode();
854
855 switch (parseState) {
856 case parsingIgnore:
857 // ignore the specified number of characters
858 parseState = (--skipCharacters > 0) ? parsingIgnore : parsingText;
859 break;
860
861 case parsingText:
862 switch (ch) {
863 case '<':
864 // hmm... we were actually inside an element
865 return QString();
866
867 case '>':
868 // we just hit an element boundary
869 parseState = parsingElementBoundary;
870 break;
871 }
872 break;
873
874 case parsingElement:
875 switch (ch) {
876 case '"': // attribute ( double quoted )
877 parseState = parsingAttributeDquote;
878 break;
879
880 case '\'': // attribute ( single quoted )
881 parseState = parsingAttributeSquote;
882 break;
883
884 case '/': // close tag
885 parseState = parsingNonElement;
886 ++nestingLevel;
887 break;
888
889 case '<':
890 // we just hit the start of the element...
891 if (nestingLevel--) {
892 break;
893 }
894
895 QString tag = str.mid(col + 1);
896 for (uint pos = 0, len = tag.length(); pos < len; ++pos) {
897 ch = tag.at(pos).unicode();
898 if (ch == ' ' || ch == '\t' || ch == '>') {
899 tag.truncate(pos);
900 break;
901 }
902 }
903 return tag;
904 }
905 break;
906
907 case parsingElementBoundary:
908 switch (ch) {
909 case '?': // processing instruction
910 case '-': // comment
911 case '/': // empty element
912 parseState = parsingNonElement;
913 break;
914
915 case '"':
916 parseState = parsingAttributeDquote;
917 break;
918
919 case '\'':
920 parseState = parsingAttributeSquote;
921 break;
922
923 case '<': // empty tag ( bad XML )
924 parseState = parsingText;
925 break;
926
927 default:
928 parseState = parsingElement;
929 }
930 break;
931
932 case parsingAttributeDquote:
933 if (ch == '"') {
934 parseState = parsingElement;
935 }
936 break;
937
938 case parsingAttributeSquote:
939 if (ch == '\'') {
940 parseState = parsingElement;
941 }
942 break;
943
944 case parsingNonElement:
945 if (ch == '<') {
946 parseState = parsingText;
947 }
948 break;
949 }
950 }
951 }
952
953 /**
954 * Return true if the tag is neither a closing tag
955 * nor an empty tag, nor a comment, nor processing instruction.
956 */
isOpeningTag(const QString & tag)957 bool PluginKateXMLToolsCompletionModel::isOpeningTag(const QString &tag)
958 {
959 return (!isClosingTag(tag) && !isEmptyTag(tag) && !tag.startsWith(QLatin1String("<?")) && !tag.startsWith(QLatin1String("<!")));
960 }
961
962 /**
963 * Return true if the tag is a closing tag. Return false
964 * if the tag is an opening tag or an empty tag ( ! )
965 */
isClosingTag(const QString & tag)966 bool PluginKateXMLToolsCompletionModel::isClosingTag(const QString &tag)
967 {
968 return (tag.startsWith(QLatin1String("</")));
969 }
970
isEmptyTag(const QString & tag)971 bool PluginKateXMLToolsCompletionModel::isEmptyTag(const QString &tag)
972 {
973 return (tag.right(2) == QLatin1String("/>"));
974 }
975
976 /**
977 * Return true if ch is a single or double quote. Expects ch to be of length 1.
978 */
isQuote(const QString & ch)979 bool PluginKateXMLToolsCompletionModel::isQuote(const QString &ch)
980 {
981 return (ch == QLatin1String("\"") || ch == QLatin1String("'"));
982 }
983
984 // ========================================================================
985 // Tools:
986
987 /// Get string describing current mode
currentModeToString() const988 QString PluginKateXMLToolsCompletionModel::currentModeToString() const
989 {
990 switch (m_mode) {
991 case entities:
992 return i18n("XML entities");
993 case attributevalues:
994 return i18n("XML attribute values");
995 case attributes:
996 return i18n("XML attributes");
997 case elements:
998 case closingtag:
999 return i18n("XML elements");
1000 default:
1001 break;
1002 }
1003 return QString();
1004 }
1005
1006 /** Sort a QStringList case-insensitively. Static. TODO: make it more simple. */
sortQStringList(QStringList list)1007 QStringList PluginKateXMLToolsCompletionModel::sortQStringList(QStringList list)
1008 {
1009 // Sort list case-insensitive. This looks complicated but using a QMap
1010 // is even suggested by the Qt documentation.
1011 QMap<QString, QString> mapList;
1012 for (const auto &str : qAsConst(list)) {
1013 if (mapList.contains(str.toLower())) {
1014 // do not override a previous value, e.g. "Auml" and "auml" are two different
1015 // entities, but they should be sorted next to each other.
1016 // TODO: currently it's undefined if e.g. "A" or "a" comes first, it depends on
1017 // the meta DTD ( really? it seems to work okay?!? )
1018 mapList[str.toLower() + '_'] = str;
1019 } else {
1020 mapList[str.toLower()] = str;
1021 }
1022 }
1023
1024 list.clear();
1025 QMap<QString, QString>::Iterator it;
1026
1027 // Qt doc: "the items are alphabetically sorted [by key] when iterating over the map":
1028 for (it = mapList.begin(); it != mapList.end(); ++it) {
1029 list.append(it.value());
1030 }
1031
1032 return list;
1033 }
1034
1035 // BEGIN InsertElement dialog
InsertElement(const QStringList & completions,QWidget * parent)1036 InsertElement::InsertElement(const QStringList &completions, QWidget *parent)
1037 : QDialog(parent)
1038 {
1039 setWindowTitle(i18n("Insert XML Element"));
1040
1041 QVBoxLayout *topLayout = new QVBoxLayout(this);
1042
1043 // label
1044 QString text = i18n("Enter XML tag name and attributes (\"<\", \">\" and closing tag will be supplied):");
1045 QLabel *label = new QLabel(text, this);
1046 label->setWordWrap(true);
1047 // combo box
1048 m_cmbElements = new KHistoryComboBox(this);
1049 static_cast<KHistoryComboBox *>(m_cmbElements)->setHistoryItems(completions, true);
1050 connect(m_cmbElements->lineEdit(), &QLineEdit::textChanged, this, &InsertElement::slotHistoryTextChanged);
1051
1052 // button box
1053 QDialogButtonBox *box = new QDialogButtonBox(this);
1054 box->setStandardButtons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
1055 m_okButton = box->button(QDialogButtonBox::Ok);
1056 m_okButton->setDefault(true);
1057
1058 connect(box, &QDialogButtonBox::accepted, this, &InsertElement::accept);
1059 connect(box, &QDialogButtonBox::rejected, this, &InsertElement::reject);
1060
1061 // fill layout
1062 topLayout->addWidget(label);
1063 topLayout->addWidget(m_cmbElements);
1064 topLayout->addWidget(box);
1065
1066 m_cmbElements->setFocus();
1067
1068 // make sure the ok button is enabled/disabled correctly
1069 slotHistoryTextChanged(m_cmbElements->lineEdit()->text());
1070 }
1071
~InsertElement()1072 InsertElement::~InsertElement()
1073 {
1074 }
1075
slotHistoryTextChanged(const QString & text)1076 void InsertElement::slotHistoryTextChanged(const QString &text)
1077 {
1078 m_okButton->setEnabled(!text.isEmpty());
1079 }
1080
text() const1081 QString InsertElement::text() const
1082 {
1083 return m_cmbElements->currentText();
1084 }
1085 // END InsertElement dialog
1086
1087 #include "plugin_katexmltools.moc"
1088
1089 // kate: space-indent on; indent-width 4; replace-tabs on; mixed-indent off;
1090