fix: replace the line scanner with a real one; three more bypasses were open
Adversarial review demonstrated that the previous fix closed one class and
left three, then reintroduced the bug this file's own history is about.
- A ``` fence in a PLAIN `//` comment earned the doctest exemption. rustdoc
runs no test there ("running 0 tests"), so two lines exempted anything after
them. The cheapest bypass yet, and `--list-doctests` in the same file already
had it right: one of the two was wrong and it was this one.
- A block comment opened after code on the same line was invisible, since only
a line STARTING with /* was seen. That is the most natural way to write one.
- Nested block comments closed at the first */ and the rest read as code.
- And the false positive: `in_block` had no reset path, so a /* inside a raw
string swallowed the rest of the file. Latent — no .rs file starts a line
with /* today, so the block fix had zero effect on this repo — but 8 files
hold raw strings and this repo's habit is Rust fixtures inside them. The
first such fixture breaks CI with a diagnosis pointing at a string.
A line-based scanner cannot decide any of these, so `scan()` now walks
characters, tracking nested block comments, strings, raw strings (with hashes)
and char literals. `#![...]` ends a run where `#[...]` stays transparent,
because a crate doc and an item doc separated by an inner attribute are two
blocks a reader does not meet as one. `#[doc = "..."]` counts: it is the one
attribute that is a comment.
The self-test carries all of it: 12 bypasses, 6 shapes that must stay legal.
Each new fix was proved discriminating by injecting it alone.
One of those injections caught a VACUOUS test of my own: with raw-string
handling disabled, a plain-string fallback reached the same verdict, so the
fixture proved nothing. It now contains r#"x"y, which a non-raw parse closes
early — so the fault is visible. That is the defect this repo is named for,
found in the test written to prevent it.
Closes improvise-mbmf, which PR #45 claimed to close and did not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
-8
File diff suppressed because one or more lines are too long
+226
-55
@@ -21,81 +21,170 @@ the rest is prose wearing a fence and the cap applies to it.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
LIMIT = 2
|
||||
|
||||
|
||||
_RAW_STR = re.compile(r'(?:b|c)?r(#*)"')
|
||||
_CHAR_LIT = re.compile(r"'(?:\\.|[^'\\])'")
|
||||
|
||||
|
||||
def scan(lines):
|
||||
"""Classify each line, respecting Rust strings and NESTED block comments.
|
||||
|
||||
A line-based scanner cannot do this: it granted the doctest exemption to
|
||||
plain `//`, missed a block comment opened after code, and read `/*` inside
|
||||
a raw string as a comment. See context/code/tooling.md.
|
||||
"""
|
||||
out = []
|
||||
in_block = 0
|
||||
in_raw = None
|
||||
in_str = False
|
||||
for raw in lines:
|
||||
i, n = 0, len(raw)
|
||||
has_code = False
|
||||
has_comment = in_block > 0
|
||||
body = None
|
||||
while i < n:
|
||||
if in_block:
|
||||
has_comment = True
|
||||
if raw.startswith("/*", i):
|
||||
in_block += 1
|
||||
i += 2
|
||||
elif raw.startswith("*/", i):
|
||||
in_block -= 1
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
if in_raw is not None:
|
||||
closer = '"' + "#" * in_raw
|
||||
if raw.startswith(closer, i):
|
||||
in_raw = None
|
||||
i += len(closer)
|
||||
else:
|
||||
i += 1
|
||||
has_code = True
|
||||
continue
|
||||
if in_str:
|
||||
has_code = True
|
||||
if raw[i] == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if raw[i] == '"':
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
if raw[i].isspace():
|
||||
i += 1
|
||||
continue
|
||||
if raw.startswith("//", i):
|
||||
has_comment = True
|
||||
if not has_code:
|
||||
body = raw[i:]
|
||||
break
|
||||
if raw.startswith("/*", i):
|
||||
has_comment = True
|
||||
in_block = 1
|
||||
i += 2
|
||||
continue
|
||||
m = _RAW_STR.match(raw, i)
|
||||
if m:
|
||||
in_raw = len(m.group(1))
|
||||
has_code = True
|
||||
i = m.end()
|
||||
continue
|
||||
m = _CHAR_LIT.match(raw, i)
|
||||
if m:
|
||||
has_code = True
|
||||
i = m.end()
|
||||
continue
|
||||
if raw[i] == '"':
|
||||
in_str = True
|
||||
has_code = True
|
||||
i += 1
|
||||
continue
|
||||
has_code = True
|
||||
i += 1
|
||||
out.append((has_code, has_comment, body))
|
||||
return out
|
||||
|
||||
|
||||
def comment_runs(lines):
|
||||
"""Yield (start_line_number, length) for each run, skipping runnable doctests.
|
||||
|
||||
Fence state and exemption are TWO facts and must be tracked separately.
|
||||
Deciding both from one flag meant a non-runnable fence's bare closing ```
|
||||
looked runnable and opened an exemption, so prose after a ```text block
|
||||
evaded the cap entirely (found by review on PR #42 -- the sixth silent
|
||||
success in this file, and the one introduced by fixing the fourth).
|
||||
Fence state and exemption are TWO facts and must be tracked separately, and
|
||||
the exemption belongs to DOC comments only -- rustdoc runs no test in a
|
||||
plain `//`. Attributes are transparent, except `#![...]`, which separates a
|
||||
crate doc from an item doc and so ends the run.
|
||||
"""
|
||||
scanned = scan(lines)
|
||||
run_start = None
|
||||
run_len = 0
|
||||
in_fence = False # are we between an opening and a closing fence?
|
||||
fence_exempt = False # is THIS fence's body a doctest rustdoc will run?
|
||||
in_block = False # inside a /* ... */ or /** ... */ comment?
|
||||
for n, raw in enumerate(lines, 1):
|
||||
s = raw.strip()
|
||||
in_fence = False
|
||||
fence_exempt = False
|
||||
attr_depth = 0
|
||||
attr_inner = False
|
||||
|
||||
# A block comment is a comment run like any other, and /** */ is a DOC
|
||||
# comment: rustdoc renders it exactly as /// does (improvise-9c8c).
|
||||
if in_block:
|
||||
if run_start is None:
|
||||
run_start, run_len = n, 0
|
||||
run_len += 1
|
||||
if "*/" in s:
|
||||
in_block = False
|
||||
continue
|
||||
if s.startswith("/*"):
|
||||
if run_start is None:
|
||||
run_start, run_len = n, 0
|
||||
run_len += 1
|
||||
if "*/" not in s[2:]:
|
||||
in_block = True
|
||||
def start(n):
|
||||
nonlocal run_start, run_len
|
||||
if run_start is None:
|
||||
run_start, run_len = n, 0
|
||||
run_len += 1
|
||||
|
||||
for n, (has_code, has_comment, body) in enumerate(scanned, 1):
|
||||
s = lines[n - 1].strip()
|
||||
is_comment = has_comment and not has_code
|
||||
|
||||
if attr_depth > 0:
|
||||
attr_depth += s.count("[") - s.count("]")
|
||||
if attr_depth <= 0:
|
||||
attr_depth = 0
|
||||
if attr_inner and run_start is not None and run_len > 0:
|
||||
yield run_start, run_len
|
||||
run_start, run_len = None, 0
|
||||
continue
|
||||
|
||||
is_comment = s.startswith("//")
|
||||
if is_comment and s.lstrip("/!").strip().startswith("```"):
|
||||
if in_fence:
|
||||
# The DELIMITERS belong to the block they close, so a runnable
|
||||
# fence's closing ``` is exempt too -- otherwise it lands in the
|
||||
# prose run and a two-line doc reads as three.
|
||||
exempt_here = fence_exempt
|
||||
in_fence = False
|
||||
fence_exempt = False
|
||||
else:
|
||||
info = s.lstrip("/!").strip()[3:].strip().lower()
|
||||
# rustdoc runs a bare or `rust` fence and does not run `text`,
|
||||
# `ignore` or `no_run`. Only a runnable body earns the exemption.
|
||||
in_fence = True
|
||||
fence_exempt = info in ("", "rust") or info.startswith("rust,")
|
||||
exempt_here = fence_exempt
|
||||
if exempt_here:
|
||||
if not is_comment and (s.startswith("#[") or s.startswith("#![")):
|
||||
# `#[doc = "..."]` is the one attribute that IS a comment.
|
||||
if s.startswith("#[doc"):
|
||||
start(n)
|
||||
continue
|
||||
# A non-runnable fence line is prose and counts like any other.
|
||||
if run_start is None:
|
||||
run_start, run_len = n, 0
|
||||
run_len += 1
|
||||
continue
|
||||
if is_comment and in_fence and fence_exempt:
|
||||
inner = s.startswith("#![")
|
||||
depth = s.count("[") - s.count("]")
|
||||
if depth > 0:
|
||||
attr_depth, attr_inner = depth, inner
|
||||
continue
|
||||
if inner and run_start is not None and run_len > 0:
|
||||
yield run_start, run_len
|
||||
run_start, run_len = None, 0
|
||||
continue
|
||||
|
||||
if is_comment:
|
||||
if run_start is None:
|
||||
run_start, run_len = n, 0
|
||||
run_len += 1
|
||||
doc = body is not None and (body.startswith("///") or body.startswith("//!"))
|
||||
text = body.lstrip("/!").strip() if body is not None else ""
|
||||
if doc and text.startswith("```"):
|
||||
if in_fence:
|
||||
exempt_here = fence_exempt
|
||||
in_fence = False
|
||||
fence_exempt = False
|
||||
else:
|
||||
info = text[3:].strip().lower()
|
||||
in_fence = True
|
||||
fence_exempt = info in ("", "rust") or info.startswith("rust,")
|
||||
exempt_here = fence_exempt
|
||||
if exempt_here:
|
||||
continue
|
||||
start(n)
|
||||
continue
|
||||
if doc and in_fence and fence_exempt:
|
||||
continue
|
||||
start(n)
|
||||
else:
|
||||
# Fence state belongs to the comment block, not to the file: an
|
||||
# unclosed ``` used to exempt every later comment in the file.
|
||||
in_fence = False
|
||||
fence_exempt = False
|
||||
if _is_attribute(s):
|
||||
continue
|
||||
if run_start is not None and run_len > 0:
|
||||
yield run_start, run_len
|
||||
run_start, run_len = None, 0
|
||||
@@ -266,6 +355,63 @@ fn c() {}
|
||||
/// two
|
||||
/// ```
|
||||
fn d() {}
|
||||
"""),
|
||||
("a fence in a PLAIN // comment earns no exemption", 7, """
|
||||
// ```
|
||||
// one
|
||||
// two
|
||||
// three
|
||||
// four
|
||||
// five
|
||||
// ```
|
||||
fn f() {}
|
||||
"""),
|
||||
("a block comment opened after code on the same line", 5, """
|
||||
fn f() {} /* one
|
||||
two
|
||||
three
|
||||
four
|
||||
five
|
||||
*/
|
||||
"""),
|
||||
("nested block comments do not close early", 7, """
|
||||
/* /* */
|
||||
one
|
||||
two
|
||||
three
|
||||
four
|
||||
five
|
||||
*/
|
||||
fn f() {}
|
||||
"""),
|
||||
("a multi-line attribute does not split a run", 4, """
|
||||
/// one
|
||||
/// two
|
||||
#[cfg(all(
|
||||
unix,
|
||||
target_pointer_width = "64"
|
||||
))]
|
||||
/// three
|
||||
/// four
|
||||
fn f() {}
|
||||
"""),
|
||||
("an attribute inside a fence does not reopen it", 5, """
|
||||
/// one
|
||||
/// ```
|
||||
/// let x = 1;
|
||||
#[inline]
|
||||
/// ```
|
||||
/// hidden A
|
||||
/// hidden B
|
||||
/// hidden C
|
||||
/// hidden D
|
||||
fn g() {}
|
||||
"""),
|
||||
("#[doc = ...] is the one attribute that is a comment", 3, """
|
||||
#[doc = "one"]
|
||||
#[doc = "two"]
|
||||
#[doc = "three"]
|
||||
fn f() {}
|
||||
"""),
|
||||
]
|
||||
|
||||
@@ -294,6 +440,31 @@ fn a() {}
|
||||
// two
|
||||
// three
|
||||
fn b() {}
|
||||
"""),
|
||||
("a block comment inside a raw string is not a comment", """
|
||||
pub const S: &str = r#"x"y /* not a comment
|
||||
one
|
||||
two
|
||||
three
|
||||
four
|
||||
"#;
|
||||
fn a() {}
|
||||
"""),
|
||||
("// inside a raw string is not a comment", """
|
||||
pub const S: &str = r#"x"y // not a comment
|
||||
// nor this
|
||||
// nor this
|
||||
// nor this
|
||||
"#;
|
||||
fn a() {}
|
||||
"""),
|
||||
("a crate doc and an item doc are separated by the inner attribute", """
|
||||
//! crate one
|
||||
//! crate two
|
||||
#![allow(clippy::needless_return)]
|
||||
/// item one
|
||||
/// item two
|
||||
pub fn go() {}
|
||||
"""),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user