Skip to content

Commit 33e4012

Browse files
committed
fix: close QDoc's code blocks on the right command, and scope its divs
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 5ae4867 commit 33e4012

5 files changed

Lines changed: 290 additions & 24 deletions

File tree

‎internal/core/format.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ var FormatByExtension = map[string][]string{
4848
`\.(?:md|mdown|markdown|markdn|[Rr]md)$`: {".md", "markup"},
4949
`\.(?:mdx)$`: {".mdx", "markup"},
5050
`\.(?:myst)$`: {".myst", "markup"},
51-
`\.(?:qdoc)$`: {".qdoc", "markup"},
51+
`\.(?:qdoc|qdocinc)$`: {".qdoc", "markup"},
5252
`\.(?:qmd)$`: {".qmd", "markup"},
5353
`\.(?:qml)$`: {".qml", "code"},
5454
`\.(?:org)$`: {".org", "markup"},

‎internal/lint/qdoc.go‎

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,36 @@ import (
1818

1919
// qdocVerbatim names the commands whose content runs to a matching `\end<X>`
2020
// and is not prose.
21-
var qdocVerbatim = map[string]struct{}{
22-
"badcode": {},
23-
"code": {},
24-
"css": {},
25-
"js": {},
26-
"qml": {},
27-
"raw": {},
21+
// The command each one ends at is not always `\end<X>`: the code variants all
22+
// close on `\endcode`, and `\oldcode` runs through `\newcode` because both
23+
// halves are code.
24+
var qdocVerbatim = map[string]string{
25+
"badcode": "endcode",
26+
"code": "endcode",
27+
"css": "endcss",
28+
"js": "endjs",
29+
"newcode": "endcode",
30+
"oldcode": "endcode",
31+
"qml": "endqml",
32+
"raw": "endraw",
2833
}
2934

3035
// qdocSkipLine names the topic, context, quoting, build-system, and
3136
// conditional commands whose whole line is markup: identifiers, file paths,
3237
// cross-references, and expressions, not prose.
3338
var qdocSkipLine = map[string]struct{}{
39+
"abstract": {},
3440
"annotatedlist": {},
41+
"attribution": {},
3542
"class": {},
3643
"cmakecomponent": {},
3744
"cmakepackage": {},
3845
"cmaketargetitem": {},
3946
"codeline": {},
47+
"compares": {},
4048
"compareswith": {},
4149
"contentspage": {},
50+
"default": {},
4251
"dontdocument": {},
4352
"dots": {},
4453
"else": {},
@@ -54,6 +63,7 @@ var qdocSkipLine = map[string]struct{}{
5463
"if": {},
5564
"include": {},
5665
"indexpage": {},
66+
"inheaderfile": {},
5767
"ingroup": {},
5868
"inherits": {},
5969
"inmodule": {},
@@ -64,23 +74,30 @@ var qdocSkipLine = map[string]struct{}{
6474
"macro": {},
6575
"meta": {},
6676
"module": {},
77+
"modulestate": {},
6778
"namespace": {},
6879
"nativetype": {},
6980
"nextpage": {},
7081
"noautolist": {},
7182
"nonreentrant": {},
83+
"notranslate": {},
84+
"omitvalue": {},
7285
"overload": {},
7386
"page": {},
87+
"preliminary": {},
7488
"previouspage": {},
7589
"printline": {},
7690
"printto": {},
7791
"printuntil": {},
7892
"property": {},
93+
"qmlabstract": {},
7994
"qmlattachedproperty": {},
8095
"qmlattachedsignal": {},
8196
"qmlbasictype": {},
8297
"qmlclass": {},
98+
"qmldefault": {},
8399
"qmlenum": {},
100+
"qmlenumeratorsfrom": {},
84101
"qmlmethod": {},
85102
"qmlmodule": {},
86103
"qmlproperty": {},
@@ -93,9 +110,11 @@ var qdocSkipLine = map[string]struct{}{
93110
"qtvariable": {},
94111
"quotefile": {},
95112
"quotefromfile": {},
113+
"readonly": {},
96114
"reentrant": {},
97115
"reimp": {},
98116
"relates": {},
117+
"required": {},
99118
"sa": {},
100119
"since": {},
101120
"sincelist": {},
@@ -107,6 +126,8 @@ var qdocSkipLine = map[string]struct{}{
107126
"tableofcontents": {},
108127
"target": {},
109128
"threadsafe": {},
129+
"toc": {},
130+
"tocentry": {},
110131
"typealias": {},
111132
"typedef": {},
112133
"variable": {},
@@ -133,13 +154,42 @@ var (
133154
// The braced {text} that may follow a braced `\l` target or `\span`
134155
// attribute.
135156
qdocLinkText = regexp.MustCompile(`^\s*\{[^{}]*\}`)
157+
// A `\div` or `\span` attribute list: `{class="header"}`.
158+
qdocClassAttr = regexp.MustCompile(`class\s*=\s*"([^"]*)"`)
159+
// A table cell's `{rows,cols}` span, which precedes the cell's prose.
160+
qdocCellSpan = regexp.MustCompile(`^\{\d+\s*,\s*\d+\}\s*`)
161+
// The `[since]` qualifier that may open a `\deprecated` line.
162+
qdocSinceQual = regexp.MustCompile(`^\[[^\]\n]*\]\s*`)
136163
)
137164

165+
// qdocClass returns the class named by a `\div` or `\span` attribute list, or
166+
// the argument itself when it is a bare name -- `\div {note}`. An unusable
167+
// class is dropped rather than guessed at, leaving an unclassed element.
168+
func qdocClass(arg string) string {
169+
arg = qdocArg(arg)
170+
if m := qdocClassAttr.FindStringSubmatch(arg); m != nil {
171+
return m[1]
172+
} else if strings.ContainsAny(arg, `="' `) {
173+
return ""
174+
}
175+
return arg
176+
}
177+
178+
// qdocOpenDiv writes a `<div>` carrying `arg`'s class, if it names one.
179+
func (c *qdocConv) openDiv(arg string) {
180+
c.flush()
181+
if class := qdocClass(arg); class != "" {
182+
c.html.WriteString(`<div class="` + class + "\">\n")
183+
return
184+
}
185+
c.html.WriteString("<div>\n")
186+
}
187+
138188
// qdocInlineNames names the inline commands, so that one starting a line is
139189
// not mistaken for a block command.
140190
var qdocInlineNames = map[string]struct{}{
141-
"a": {}, "b": {}, "c": {}, "e": {}, "l": {}, "sub": {}, "sup": {},
142-
"tt": {}, "uicontrol": {}, "underline": {},
191+
"a": {}, "b": {}, "bold": {}, "c": {}, "e": {}, "i": {}, "l": {},
192+
"sub": {}, "sup": {}, "tt": {}, "uicontrol": {}, "underline": {},
143193
}
144194

145195
// qdocArg strips the braces from a command argument.
@@ -181,9 +231,9 @@ func qdocInline(text string) string {
181231
switch name {
182232
case "c", "tt", "a":
183233
out.WriteString("<code>" + qdocArg(arg) + "</code>")
184-
case "b", "uicontrol":
234+
case "b", "bold", "uicontrol":
185235
out.WriteString("<strong>" + qdocArg(arg) + "</strong>")
186-
case "e":
236+
case "e", "i":
187237
out.WriteString("<em>" + qdocArg(arg) + "</em>")
188238
case "underline":
189239
out.WriteString("<u>" + qdocArg(arg) + "</u>")
@@ -199,14 +249,18 @@ func qdocInline(text string) string {
199249
}
200250
out.WriteString(`<a href="#">` + label + "</a>")
201251
case "span":
202-
// `\span {class="x"} {text}`: the attribute is markup, the
203-
// braced text that follows is prose.
252+
// `\span {class="x"} {text}`: the attribute names a class a rule
253+
// can be scoped to, the braced text that follows is prose.
204254
label := ""
205255
if m := qdocLinkText.FindString(rest); m != "" {
206256
label = qdocArg(strings.TrimSpace(m))
207257
rest = rest[len(m):]
208258
}
209-
out.WriteString("<span>" + label + "</span>")
259+
if class := qdocClass(arg); class != "" {
260+
out.WriteString(`<span class="` + class + `">` + label + "</span>")
261+
} else {
262+
out.WriteString("<span>" + label + "</span>")
263+
}
210264
case "image", "inlineimage":
211265
// The argument is a file name; a caption, if any, follows as
212266
// ordinary prose.
@@ -276,6 +330,9 @@ func (c *qdocConv) item(rest string) {
276330
top := &c.stack[n-1]
277331
top.inItem = true
278332

333+
// A table cell may open with a `{rows,cols}` span, which is markup.
334+
rest = qdocCellSpan.ReplaceAllString(rest, "")
335+
279336
tag := "li"
280337
if top.kind == "table" {
281338
tag = "td"
@@ -322,12 +379,31 @@ func qdocBlankDelims(raw string) string {
322379
return raw
323380
}
324381

382+
// endsVerbatim reports whether name closes the open verbatim block. Both the
383+
// command QDoc documents and the symmetrical `\end<X>` are accepted, so a
384+
// source that writes `\endbadcode` is not read as unterminated.
385+
func (c *qdocConv) endsVerbatim(name string) bool {
386+
return name == qdocVerbatim[c.verbatim] || name == "end"+c.verbatim
387+
}
388+
325389
func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command family
390+
// A verbatim or omitted block cannot outlive the comment holding it. An
391+
// unterminated one -- a `\code` whose `\endcode` was never written, or a
392+
// spelling this converter does not know -- would otherwise swallow the
393+
// rest of the file.
394+
if strings.HasSuffix(strings.TrimSpace(raw), "*/") {
395+
if c.verbatim != "" {
396+
c.html.WriteString("</code></pre>\n")
397+
c.verbatim = ""
398+
}
399+
c.omitted = false
400+
}
401+
326402
raw = qdocBlankDelims(raw)
327403
trimmed := strings.TrimSpace(raw)
328404

329405
if c.verbatim != "" {
330-
if m := qdocBlockCmd.FindStringSubmatch(raw); m != nil && m[1] == c.verbatim {
406+
if m := qdocBlockCmd.FindStringSubmatch(raw); m != nil && c.endsVerbatim(m[1]) {
331407
c.verbatim = ""
332408
c.html.WriteString("</code></pre>\n")
333409
}
@@ -364,7 +440,7 @@ func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command f
364440
c.omitted = true
365441
case func() bool { _, ok := qdocVerbatim[name]; return ok }():
366442
c.flush()
367-
c.verbatim = "end" + name
443+
c.verbatim = name
368444
c.html.WriteString("<pre><code>")
369445
case func() bool { _, ok := qdocSkipLine[name]; return ok }():
370446
c.flush()
@@ -378,6 +454,16 @@ func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command f
378454
case name == "title":
379455
c.flush()
380456
c.html.WriteString("<h1>" + qdocInline(rest) + "</h1>\n")
457+
case name == "subtitle":
458+
c.flush()
459+
c.html.WriteString(`<h2 class="subtitle">` + qdocInline(rest) + "</h2>\n")
460+
case name == "deprecated":
461+
// `\deprecated [6.0] Use \l Foo instead.`: the version is markup,
462+
// the replacement advice that may follow it is prose.
463+
c.flush()
464+
if rest = qdocSinceQual.ReplaceAllString(rest, ""); rest != "" {
465+
c.para = append(c.para, rest)
466+
}
381467
case strings.HasPrefix(name, "section") && len(name) == 8 && name[7] >= '1' && name[7] <= '4':
382468
c.closeDiv()
383469
level := string('1' + name[7] - '0')
@@ -425,9 +511,15 @@ func (c *qdocConv) line(raw string) { //nolint:gocyclo // one case per command f
425511
c.flush()
426512
c.html.WriteString("</blockquote>\n")
427513
case name == "div":
514+
c.openDiv(rest)
515+
case name == "details":
516+
// `\details {Summary text}`: the summary is prose of its own.
428517
c.flush()
429-
c.html.WriteString("<div>\n")
430-
case name == "enddiv":
518+
c.html.WriteString(`<div class="details">` + "\n")
519+
if summary := qdocArg(rest); summary != "" {
520+
c.html.WriteString(`<p class="summary">` + qdocInline(summary) + "</p>\n")
521+
}
522+
case name == "enddiv", name == "enddetails":
431523
c.flush()
432524
c.html.WriteString("</div>\n")
433525
case name == "legalese":

‎internal/lint/qdoc_test.go‎

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,70 @@ func TestQdocHTML(t *testing.T) {
168168
[]string{"0x2022"},
169169
},
170170
{
171-
"a span's attribute is markup, its text prose",
171+
"a span's class scopes its text, which is prose",
172172
"See \\span {class=\"vrheader\"} {the spanned words} here.\n",
173-
[]string{"<span>the spanned words</span>", "here."},
174-
[]string{"vrheader", "class="},
173+
[]string{`<span class="vrheader">the spanned words</span>`, "here."},
174+
nil,
175+
},
176+
{
177+
"a div's class scopes the prose it holds",
178+
"\\div {class=\"note\"}\nProse inside.\n\\enddiv\n",
179+
[]string{`<div class="note">`, "Prose inside.", "</div>"},
180+
[]string{"class=\"class"},
181+
},
182+
{
183+
"details keeps its summary as prose",
184+
"\\details {A summary sentence.}\nBody prose.\n\\enddetails\n",
185+
[]string{`<div class="details">`, `<p class="summary">A summary sentence.</p>`,
186+
"Body prose.", "</div>"},
187+
nil,
188+
},
189+
{
190+
"a subtitle is a heading",
191+
"\\title The Title\n\\subtitle The Subtitle\n",
192+
[]string{"<h1>The Title</h1>", `<h2 class="subtitle">The Subtitle</h2>`},
193+
nil,
194+
},
195+
{
196+
"a deprecation's version is markup, its advice prose",
197+
"\\deprecated [6.5] Use the replacement instead.\n",
198+
[]string{"Use the replacement instead."},
199+
[]string{"6.5"},
200+
},
201+
{
202+
"a bare deprecation says nothing",
203+
"\\deprecated\n\nProse here.\n",
204+
[]string{"<p>Prose here.</p>"},
205+
[]string{"deprecated"},
206+
},
207+
{
208+
"a cell span is markup, the cell's text prose",
209+
"\\table\n \\row\n \\li {2,1} Spanned cell prose.\n\\endtable\n",
210+
[]string{"<td>Spanned cell prose."},
211+
[]string{"2,1"},
212+
},
213+
{
214+
"deprecated inline aliases keep their text",
215+
"See \\bold {bold words} and \\i {italic words}.\n",
216+
[]string{"<strong>bold words</strong>", "<em>italic words</em>"},
217+
[]string{"\\bold", "\\i"},
218+
},
219+
{
220+
"a no-argument command keeps the words after it",
221+
"Qt \\tm is a trademark, and \\br a line break.\n",
222+
[]string{"is a trademark", "a line break."},
223+
[]string{"\\tm", "\\br"},
224+
},
225+
{
226+
"modifier and metadata commands are markup",
227+
"\\abstract\n\\readonly\n\\required\n\\preliminary\n\\qmlabstract\n" +
228+
"\\qmldefault\n\\qmlenumeratorsfrom Mode\n\\omitvalue Hidden\n" +
229+
"\\modulestate {Technical Preview}\n\\notranslate\n\\compares strong\n" +
230+
"\\default true\n\\inheaderfile QtCore\n\\attribution Upstream\n" +
231+
"\\toc\n\\tocentry Entry\n\nProse here.\n",
232+
[]string{"<p>Prose here.</p>"},
233+
[]string{"Mode", "Hidden", "Technical", "strong", "true", "QtCore",
234+
"Upstream", "Entry"},
175235
},
176236
}
177237

@@ -192,3 +252,30 @@ func TestQdocHTML(t *testing.T) {
192252
})
193253
}
194254
}
255+
256+
// TestQdocVerbatimClose pins how a non-prose block ends. The code variants all
257+
// close on \endcode, and none of them may run past the comment that holds it.
258+
func TestQdocVerbatimClose(t *testing.T) {
259+
cases := []struct {
260+
name string
261+
in string
262+
}{
263+
{"badcode closes on endcode", "\\badcode\n int x = FIXME;\n\\endcode\n\nProse here.\n"},
264+
{"oldcode runs through newcode", "\\oldcode\n FIXME old;\n\\newcode\n FIXME new;\n\\endcode\n\nProse here.\n"},
265+
{"a symmetrical end is accepted too", "\\badcode\n int x = FIXME;\n\\endbadcode\n\nProse here.\n"},
266+
{"an unterminated block ends with the comment",
267+
"/*!\n\\code\n int x = FIXME;\n*/\n\n/*!\nProse here.\n*/\n"},
268+
}
269+
270+
for _, c := range cases {
271+
t.Run(c.name, func(t *testing.T) {
272+
html := qdocToHTML(c.in)
273+
if !strings.Contains(html, "<p>Prose here.</p>") {
274+
t.Errorf("prose did not survive the block:\n%s", html)
275+
}
276+
if strings.Contains(html, "FIXME") {
277+
t.Errorf("code leaked into prose:\n%s", html)
278+
}
279+
})
280+
}
281+
}

0 commit comments

Comments
 (0)