diff --git a/scripts/README.md b/scripts/README.md index 5f7c7816..913a1c74 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,7 +41,7 @@ The `semicolon` and `dash` rules ban a construction rather than a detectable sub The `comment-wrap` rule covers comments in every syntax the fleet's project types carry, not only the hash ones: `//` and `/* */` for C#, C, C++ and JSONC, `/* */` alone for CSS, `` for XML, csproj and markdown, `<# #>` for PowerShell, `;` for INI, and `#` for Python, shell, YAML and TOML. -JSON is treated as JSONC, because that is what ships: VS Code tasks, launch, devcontainer and workspace files all carry comments under a plain `.json` name. A marker inside a string literal is not a comment, so each line is scanned with quoted spans blanked first, and Python uses `tokenize` so a trailing comment is seen exactly. A documentation comment (`///`, `/**`, a docstring) is left to CODESTYLE, which permits the paragraphs this rule forbids. +JSON is treated as JSONC, because that is what ships: VS Code tasks, launch, devcontainer and workspace files all carry comments under a plain `.json` name. A marker inside a string literal is not a comment, so each line is scanned with quoted spans blanked first, and Python uses `tokenize` so a trailing comment is seen exactly. Blanking is per line, which suits a string that ends on the line it starts. The C# verbatim string is the one form carried across lines. Every other syntax is scanned a line at a time, so any string that spans lines leaves its markers readable, and an ordinary quoted string in shell, a PowerShell here-string, a YAML block scalar, and a heredoc all report a marker inside them as a comment. A documentation comment (`///`, `/**`, a docstring) is left to CODESTYLE, which permits the paragraphs this rule forbids. A comment sentence also has to start with a capital, which `comment-case` checks. A lowercase opening reads as the continuation of the line above it, so the two rules are read together: a wrapped sentence reports as `comment-wrap`, and a lowercase opening that is not a continuation reports as `comment-case`. Where the first word is a tool whose own casing is lowercase, the fix is to restructure rather than to capitalize the name against CODESTYLE's tooling-casing rule. diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 037c1b71..fefe0671 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -250,17 +250,19 @@ def syntax_for(path: Path) -> Syntax | None: return HASH if not suffix else None -def strip_strings(line: str, quotes: str, verbatim: bool = False) -> str: +def strip_strings(line: str, quotes: str, verbatim: bool = False, + carried: bool = False) -> tuple[str, bool]: """Blank quoted spans so a comment marker inside a string is not read as one. Length-preserving, so an offset into the result is an offset into the line. A verbatim string takes its own rules where the syntax has one. There the backslash is an ordinary character and a doubled quote is the escape, so reading a backslash as an escape consumes the closing quote and blanks the rest of the line. + It also spans lines, so `carried` opens one and the second return says it is still open. """ out = list(line) - quote = '' - inside_verbatim = False + quote = '"' if carried else '' + inside_verbatim = carried escaped = False i = 0 while i < len(line): @@ -292,7 +294,7 @@ def strip_strings(line: str, quotes: str, verbatim: bool = False) -> str: start -= 1 inside_verbatim = verbatim and ch == '"' and '@' in line[start:i] i += 1 - return ''.join(out) + return ''.join(out), inside_verbatim # A pragma, shebang, or divider is machinery rather than prose. @@ -399,9 +401,9 @@ def extracted_comments(path: Path, lines: list[str]) -> list[tuple[int, str, boo out: list[tuple[int, str, bool]] = [] closing = '' doc_closing = '' + in_string = False for n, raw in enumerate(lines, 1): line = raw.rstrip('\r') - masked = strip_strings(line, spec['quotes'], spec['verbatim']) pos = 0 if doc_closing: # CODESTYLE owns every line until it closes end = line.find(doc_closing) @@ -419,6 +421,10 @@ def extracted_comments(path: Path, lines: list[str]) -> list[tuple[int, str, boo # Scan left to right and take whichever marker comes first. # A ceiling can only describe the first comment, so a later one was unreachable. while pos < len(line): + # Mask from here rather than once per line, so comment text never sets string state. + # A quote in a comment is prose, and reading it as a string blanks the markers after it. + tail, tail_state = strip_strings(line[pos:], spec['quotes'], spec['verbatim'], in_string) + masked = ' ' * pos + tail found: str | tuple[str, str] | None = None at = len(line) for marker in spec['line']: @@ -430,13 +436,16 @@ def extracted_comments(path: Path, lines: list[str]) -> list[tuple[int, str, boo if 0 <= where < at: at, found = where, (opener, closer) if found is None: + in_string = tail_state # the rest of the line is code break + # Only the code before the marker advances the string state. + _, in_string = strip_strings(line[pos:at], spec['quotes'], spec['verbatim'], in_string) # CODESTYLE owns a documentation comment, so this rule skips over it. # A line one runs to end of line, while a closed block one gives the rest back. if any(line[at:].startswith(d) for d in spec['doc']): if isinstance(found, str): break - end = masked.find(found[1], at + len(found[0])) + end = line.find(found[1], at + len(found[0])) if end < 0: doc_closing = found[1] # it carries on into the lines below break @@ -449,7 +458,7 @@ def extracted_comments(path: Path, lines: list[str]) -> list[tuple[int, str, boo out.append((n, body, leading)) break opener, closer = found - end = masked.find(closer, at + len(opener)) + end = line.find(closer, at + len(opener)) # a quote in the comment is prose body = (line[at + len(opener):end if end >= 0 else None]).strip().lstrip('*').strip() if body: out.append((n, body, leading)) diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index 005d9b76..b7103fb0 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -324,9 +324,44 @@ def test_verbatim_rules_apply_to_the_double_quoted_form_only(self) -> None: Under verbatim rules the doubled quote is one escaped character and both are blanked, so counting what survives tells the two readings apart. """ - masked = prose_lint.strip_strings("var c = @'a''b'; // t", '"\'', True) + masked, _ = prose_lint.strip_strings("var c = @'a''b'; // t", '"\'', True) self.assertEqual(4, masked.count("'")) + def test_a_verbatim_string_spans_lines(self) -> None: + """It is the one string form here that carries, so masking per line invents comments. + + The line that closes it still gives back what follows the quote. + """ + # The marker on the second line is string content, so nothing is reported. + self.assertEqual([], self.flag('a.cs', 'var s = @"line one\n' + '// Two things. Here.\n' + 'line three";\n')) + # The line that closes it still gives back the comment after the quote. + self.assertEqual(['comment-wrap'], self.flag('a.cs', 'var s = @"line one\n' + 'line two"; // Two things. Here.\n')) + # A plain string ends on its own line, so the next line is ordinary code. + self.assertEqual(['comment-wrap'], self.flag('a.cs', 'var s = "line one";\n' + '// Two things. Here.\n')) + # Closing one and opening another leaves real code between them, which is not string content. + self.assertEqual(['comment-wrap'], + self.flag('a.cs', 'var s = @"start\n' + 'end"; /* Two things. Here. */ var t = @"open again\n' + 'still string";\n')) + + def test_a_quote_in_comment_text_is_prose_rather_than_a_string(self) -> None: + """Masking the comment too lets its quote open a string that blanks the markers after it. + + Within the line that costs the block its closer, and across lines the state carries and + blanks every marker below until something closes it. + """ + self.assertEqual(['comment-wrap'], + self.flag('a.cs', 'code(); /* note @"x */ code2(); // Two things. Here.\n')) + # Each block line is its own sentence, so the two findings are the recovered comments. + self.assertEqual(['comment-wrap', 'comment-wrap'], + self.flag('a.cs', '/* A note about @"paths.\n' + ' And more. */ // Two things. Here.\n' + 'var x = 1; // Two things. Here.\n')) + def test_a_format_with_no_comment_syntax_is_skipped(self) -> None: for name in ('a.lock', 'a.csv', 'a.txt'): with self.subTest(file=name):