Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 16 additions & 7 deletions scripts/prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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']:
Expand All @@ -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
Expand All @@ -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))
Expand Down
37 changes: 36 additions & 1 deletion scripts/test_prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading