Skip to content

Commit 54efd3b

Browse files
committed
fix: escape QDoc markup
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 5666297 commit 54efd3b

5 files changed

Lines changed: 223 additions & 22 deletions

File tree

‎internal/lint/fragment.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func (l *Linter) lintFragments(f *core.File) error {
123123
case ".org":
124124
err = l.lintOrg(f)
125125
case ".qdoc":
126-
err = l.lintQDoc(f)
126+
err = l.lintQDocFragment(f)
127127
default:
128128
return fmt.Errorf("unsupported markup format '%s'", f.NormedExt)
129129
}

‎internal/lint/qdoc.go‎

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func (c *qdocConv) meta(kind, value string) {
180180
if value == "" {
181181
return
182182
}
183-
c.html.WriteString(`<data class="` + kind + `">` + value + "</data>\n")
183+
c.html.WriteString(`<data class="` + kind + `">` + qdocEsc(value) + "</data>\n")
184184
}
185185

186186
// qdocOpenDiv writes a `<div>` carrying `arg`'s class, if it names one.
@@ -200,6 +200,17 @@ var qdocInlineNames = map[string]struct{}{
200200
"sub": {}, "sup": {}, "tt": {}, "uicontrol": {}, "underline": {},
201201
}
202202

203+
// qdocEsc makes text safe to write into the HTML the walker reads.
204+
//
205+
// QDoc prose is kept word-for-word, and Qt's prose talks about markup: a
206+
// sentence explaining that a file "must be included using a <script> tag"
207+
// carries a real tag. Written straight through, it opens an element in the
208+
// converted document -- and `<script>` in particular puts the tokenizer into
209+
// raw-text mode, so the rest of the page is swallowed looking for a close
210+
// that never comes. The tokenizer decodes these again, so the text a rule
211+
// sees, and the text located in the source, are unchanged.
212+
var qdocEsc = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace
213+
203214
// qdocArg strips the braces from a command argument.
204215
func qdocArg(arg string) string {
205216
if strings.HasPrefix(arg, "{") && strings.HasSuffix(arg, "}") {
@@ -217,10 +228,10 @@ func qdocInline(text string) string {
217228
for {
218229
loc := qdocInlineCmd.FindStringSubmatchIndex(text)
219230
if loc == nil {
220-
out.WriteString(text)
231+
out.WriteString(qdocEsc(text))
221232
break
222233
}
223-
out.WriteString(text[:loc[0]])
234+
out.WriteString(qdocEsc(text[:loc[0]]))
224235

225236
if loc[2] < 0 {
226237
// `\\` is a literal backslash, not a command.
@@ -238,15 +249,15 @@ func qdocInline(text string) string {
238249

239250
switch name {
240251
case "c", "tt", "a":
241-
out.WriteString("<code>" + qdocArg(arg) + "</code>")
252+
out.WriteString("<code>" + qdocEsc(qdocArg(arg)) + "</code>")
242253
case "b", "bold", "uicontrol":
243-
out.WriteString("<strong>" + qdocArg(arg) + "</strong>")
254+
out.WriteString("<strong>" + qdocEsc(qdocArg(arg)) + "</strong>")
244255
case "e", "i":
245-
out.WriteString("<em>" + qdocArg(arg) + "</em>")
256+
out.WriteString("<em>" + qdocEsc(qdocArg(arg)) + "</em>")
246257
case "underline":
247-
out.WriteString("<u>" + qdocArg(arg) + "</u>")
258+
out.WriteString("<u>" + qdocEsc(qdocArg(arg)) + "</u>")
248259
case "sub", "sup":
249-
out.WriteString("<" + name + ">" + qdocArg(arg) + "</" + name + ">")
260+
out.WriteString("<" + name + ">" + qdocEsc(qdocArg(arg)) + "</" + name + ">")
250261
case "l":
251262
label := qdocArg(arg)
252263
if strings.HasPrefix(arg, "{") {
@@ -255,7 +266,7 @@ func qdocInline(text string) string {
255266
rest = rest[len(m):]
256267
}
257268
}
258-
out.WriteString(`<a href="#">` + label + "</a>")
269+
out.WriteString(`<a href="#">` + qdocEsc(label) + "</a>")
259270
case "span":
260271
// `\span {class="x"} {text}`: the attribute names a class a rule
261272
// can be scoped to, the braced text that follows is prose.
@@ -265,9 +276,9 @@ func qdocInline(text string) string {
265276
rest = rest[len(m):]
266277
}
267278
if class := qdocClass(arg); class != "" {
268-
out.WriteString(`<span class="` + class + `">` + label + "</span>")
279+
out.WriteString(`<span class="` + class + `">` + qdocEsc(label) + "</span>")
269280
} else {
270-
out.WriteString("<span>" + label + "</span>")
281+
out.WriteString("<span>" + qdocEsc(label) + "</span>")
271282
}
272283
case "image", "inlineimage":
273284
// The argument is a file name; a caption, if any, follows as
@@ -301,6 +312,11 @@ type qdocConv struct {
301312
stack []qdocContext
302313
verbatim string // the \end command that closes the open verbatim block
303314
omitted bool
315+
316+
// inComment says whether the reader is inside a `/*! ... */` block, the
317+
// only place QDoc looks. A comment body extracted from a source file
318+
// starts inside one; a whole file starts outside.
319+
inComment bool
304320
}
305321

306322
func (c *qdocConv) flush() {
@@ -394,20 +410,46 @@ func (c *qdocConv) endsVerbatim(name string) bool {
394410
return name == qdocVerbatim[c.verbatim] || name == "end"+c.verbatim
395411
}
396412

397-
func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command family
413+
// opensComment reports whether the line starts a `/*!` documentation comment.
414+
func qdocOpensComment(raw string) bool {
415+
i := strings.Index(raw, "/*!")
416+
return i >= 0 && strings.TrimSpace(raw[:i]) == ""
417+
}
418+
419+
// line reads one line of a QDoc source, tracking which of them the reader is
420+
// meant to see at all.
421+
//
422+
// QDoc documents a project from its `/*! ... */` comments and nothing else.
423+
// A `.qdoc` file is a file of those comments, and what sits between them --
424+
// a licence header, a `//! [name]` snippet whose body is shell or C++ -- is
425+
// no more prose than the code in a `.cpp` file is.
426+
func (c *qdocConv) line(raw string) {
427+
closes := strings.HasSuffix(strings.TrimSpace(raw), "*/")
428+
398429
// A verbatim or omitted block cannot outlive the comment holding it. An
399430
// unterminated one -- a `\code` whose `\endcode` was never written, or a
400431
// spelling this converter does not know -- would otherwise swallow the
401432
// rest of the file.
402-
if strings.HasSuffix(strings.TrimSpace(raw), "*/") {
433+
if closes {
403434
if c.verbatim != "" {
404435
c.html.WriteString("</code></pre>\n")
405436
c.verbatim = ""
406437
}
407438
c.omitted = false
408439
}
409440

410-
raw = qdocBlankDelims(raw)
441+
if c.inComment || qdocOpensComment(raw) {
442+
c.inComment = true
443+
c.content(qdocBlankDelims(raw))
444+
}
445+
446+
if closes {
447+
c.inComment = false
448+
c.closeDiv()
449+
}
450+
}
451+
452+
func (c *qdocConv) content(raw string) { //nolint:gocyclo // one case per command family
411453
trimmed := strings.TrimSpace(raw)
412454

413455
if c.verbatim != "" {
@@ -516,7 +558,7 @@ func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command f
516558
c.flush()
517559
fields := strings.Fields(rest)
518560
if len(fields) > 1 {
519-
c.html.WriteString("<p><code>" + fields[0] + "</code> " +
561+
c.html.WriteString("<p><code>" + qdocEsc(fields[0]) + "</code> " +
520562
qdocInline(strings.Join(fields[1:], " ")) + "</p>\n")
521563
}
522564
case name == "quotation":
@@ -570,8 +612,16 @@ func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command f
570612
}
571613
}
572614

573-
func qdocToHTML(content string) string {
574-
conv := &qdocConv{}
615+
// qdocToHTML converts a whole QDoc source: the `/*! ... */` comments in it
616+
// are the document, and everything else is code.
617+
func qdocToHTML(content string) string { return qdocConvert(content, false) }
618+
619+
// qdocFragmentToHTML converts the body of a single comment, already extracted
620+
// from a source file, so there is no `/*!` left to wait for.
621+
func qdocFragmentToHTML(content string) string { return qdocConvert(content, true) }
622+
623+
func qdocConvert(content string, inComment bool) string {
624+
conv := &qdocConv{inComment: inComment}
575625
for _, line := range strings.Split(content, "\n") {
576626
conv.line(line)
577627
}
@@ -580,8 +630,17 @@ func qdocToHTML(content string) string {
580630
return conv.html.String()
581631
}
582632

583-
// lintQDoc lints QDoc: Qt's documentation markup.
633+
// lintQDoc lints a QDoc source: Qt's documentation markup.
584634
func (l *Linter) lintQDoc(f *core.File) error {
635+
return l.lintQDocWith(f, qdocToHTML)
636+
}
637+
638+
// lintQDocFragment lints one QDoc comment lifted out of a code file.
639+
func (l *Linter) lintQDocFragment(f *core.File) error {
640+
return l.lintQDocWith(f, qdocFragmentToHTML)
641+
}
642+
643+
func (l *Linter) lintQDocWith(f *core.File, convert func(string) string) error {
585644
err := l.lintMetadata(f)
586645
if err != nil {
587646
return err
@@ -592,5 +651,5 @@ func (l *Linter) lintQDoc(f *core.File) error {
592651
return err
593652
}
594653

595-
return l.lintHTMLTokens(f, []byte(qdocToHTML(s)), 0)
654+
return l.lintHTMLTokens(f, []byte(convert(s)), 0)
596655
}

‎internal/lint/qdoc_test.go‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ func TestQdocHTML(t *testing.T) {
116116
[]string{"Prose one.", "Prose two."},
117117
[]string{"intro", "//!"},
118118
},
119+
{
120+
"prose about markup stays prose",
121+
"The file must be included using a <script> tag.\n\nProse after it.\n",
122+
[]string{"using a &lt;script&gt; tag.", "<p>Prose after it.</p>"},
123+
nil,
124+
},
119125
{
120126
"sa lines are markup",
121127
"Prose here.\n\n\\sa QWidget, QObject\n",
@@ -251,7 +257,7 @@ func TestQdocHTML(t *testing.T) {
251257

252258
for _, c := range cases {
253259
t.Run(c.name, func(t *testing.T) {
254-
html := qdocToHTML(c.in)
260+
html := qdocFragmentToHTML(c.in)
255261

256262
for _, want := range c.want {
257263
if !strings.Contains(html, want) {
@@ -283,7 +289,7 @@ func TestQdocVerbatimClose(t *testing.T) {
283289

284290
for _, c := range cases {
285291
t.Run(c.name, func(t *testing.T) {
286-
html := qdocToHTML(c.in)
292+
html := qdocFragmentToHTML(c.in)
287293
if !strings.Contains(html, "<p>Prose here.</p>") {
288294
t.Errorf("prose did not survive the block:\n%s", html)
289295
}
@@ -293,3 +299,36 @@ func TestQdocVerbatimClose(t *testing.T) {
293299
})
294300
}
295301
}
302+
303+
// TestQdocWholeFile pins that a `.qdoc` file is a file of `/*! ... */`
304+
// comments: a licence header, a `//! [name]` snippet body, and anything else
305+
// between them is code, however much it reads like prose.
306+
func TestQdocWholeFile(t *testing.T) {
307+
const src = `// Copyright (C) 2016 The Qt Company Ltd.
308+
// SPDX-License-Identifier: LicenseRef-Qt-Commercial
309+
310+
//! [15]
311+
md build_shared
312+
cd build_shared
313+
//! [15]
314+
315+
/*!
316+
\title A Real Heading
317+
Real prose here.
318+
*/
319+
320+
Trailing text outside any comment.
321+
`
322+
323+
html := qdocToHTML(src)
324+
for _, want := range []string{"<h1>A Real Heading</h1>", "Real prose here."} {
325+
if !strings.Contains(html, want) {
326+
t.Errorf("missing %q in:\n%s", want, html)
327+
}
328+
}
329+
for _, absent := range []string{"Copyright", "SPDX", "build_shared", "Trailing"} {
330+
if strings.Contains(html, absent) {
331+
t.Errorf("content outside a comment was kept (%q):\n%s", absent, html)
332+
}
333+
}
334+
}

‎testdata/e2e/lint.yaml‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,10 +380,107 @@ cases:
380380
test.qdoc:108:70:vale.Annotations:'TODO' left in text
381381
test.qdoc:113:45:vale.Annotations:'TODO' left in text
382382
test.qdoc:116:51:vale.Annotations:'XXX' left in text
383+
test.qdoc:120:45:vale.Annotations:'TODO' left in text
384+
test.qdoc:122:42:vale.Annotations:'XXX' left in text
383385
absent:
384386
- FIXME
385387
- NOTE
386388

389+
- name: qdoc-line-accuracy
390+
about: an alert keeps its source line through the constructs a real Qt
391+
page is built from -- a link split across lines, snippet markers,
392+
custom macros, and a snippet reference
393+
files:
394+
.vale.ini: |
395+
StylesPath = styles
396+
MinAlertLevel = suggestion
397+
398+
[*]
399+
T.Mark = YES
400+
styles/T/Mark.yml: |
401+
extends: existence
402+
message: "'%s'"
403+
level: error
404+
nonword: true
405+
raw:
406+
- MARK[A-E]
407+
crosslink.qdoc: |
408+
/*!
409+
\page qt-edu-for-designers.html
410+
\title Qt Edu for Designers
411+
412+
//! [intro]
413+
\e {Qt Edu for Designers} package contains \l {Qt Design Studio Manual}
414+
{Qt Design Studio Enterprise}, allowing you to MARKA import.
415+
//! [intro]
416+
417+
These instructions walk you through the installation.
418+
419+
\include qt-edu-steps.qdocinc {qt-edu-common-steps} {Qt Design Studio}
420+
421+
\image qt-edu-install-design-studio.png
422+
423+
With \e {Qt Edu for Designers} license, you get access to Qt Design
424+
Studio. Select the \uicontrol {Design Tools} shortcut to install.
425+
426+
Select \uicontrol {Next}.
427+
428+
\include qt-edu-steps.qdocinc qt-edu-license-agreement
429+
430+
\section1 9. Finish the installation
431+
432+
Select \uicontrol {Install} to start the installation process.
433+
434+
Once installation is complete, you'll see the screen.
435+
436+
\image qt-edu-install-finish-design-studio.png
437+
438+
Select \uicontrol {Finish} to exit the installer.
439+
440+
\section1 10. Open Qt Design Studio
441+
442+
Go to the MARKB folder to see the tools that were installed.
443+
Qt Design Studio is MARKC located under its own folder.
444+
*/
445+
macros.qdoc: |
446+
/*!
447+
\class Example
448+
\brief A brief description.
449+
450+
This works on \macos and \linux MARKA platforms.
451+
The \QUL framework handles MARKB rendering accordingly.
452+
Final sentence after MARKC macros.
453+
*/
454+
markers.qdoc: |
455+
/*!
456+
//! [intro]
457+
Prose line MARKA three.
458+
//! [intro]
459+
460+
Prose line MARKB six.
461+
*/
462+
snippet.qdoc: |
463+
/*!
464+
\class Loader
465+
\brief Loads resources.
466+
467+
Use the loader as follows:
468+
\snippet examples/loader.cpp setup
469+
Prose after MARKA snippet should be included.
470+
*/
471+
args: .
472+
exit: 1
473+
want: |
474+
crosslink.qdoc:7:52:T.Mark:'MARKA'
475+
crosslink.qdoc:35:15:T.Mark:'MARKB'
476+
crosslink.qdoc:36:25:T.Mark:'MARKC'
477+
macros.qdoc:5:37:T.Mark:'MARKA'
478+
macros.qdoc:6:32:T.Mark:'MARKB'
479+
macros.qdoc:7:26:T.Mark:'MARKC'
480+
markers.qdoc:3:16:T.Mark:'MARKA'
481+
markers.qdoc:6:16:T.Mark:'MARKB'
482+
snippet.qdoc:7:17:T.Mark:'MARKA'
483+
387484
- name: qdoc-classes
388485
about: "`.qdocinc` is QDoc without configuration, and `\\div` and `\\span`
389486
carry their class into the scope so a rule can target the prose they

‎testdata/fixtures/formats/test.qdoc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,9 @@
115115

116116
\image screenshot-FIXME.png A caption with an XXX in it.
117117
*/
118+
119+
/*!
120+
Include it with a <script> tag, and the TODO after it still counts.
121+
122+
Prose in a later paragraph keeps its XXX.
123+
*/

0 commit comments

Comments
 (0)