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