blob: a2afff93e9d43480edd94c61e2307083670dc941 [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
TODO-288: Normalize generic type-argument comma spacing.
Rewrites `Foo<A, B>` -> `Foo<A,B>` (at every nesting level, e.g.
`Map<K, List<V>>` -> `Map<K,List<V>>`) everywhere a "generic-shaped"
`<...>` span can be found, INCLUDING inside Javadoc/comments
(`{@link Foo<A, B>}`, `{@code Map<String, Object>}`, and prose like
"returns a `Map<String, Object>`"), per the maintainer decision that
Javadoc/comments are in scope for the one-shot sweep (the Eclipse
formatter can't rewrite comment text, so this script is the mechanism
for that half of the job).
This is intentionally a lexer-style, not a full-AST, transform:
JavaParser (or any real AST) will happily rewrite `ParameterizedType`
argument lists in *code*, but has no mandate -- and no printer support
-- for rewriting free-text *inside* Javadoc/comments. Since decision #2
requires touching comment text too, an AST-only approach can't satisfy
the spec by itself, and bolting a second comment-text pass onto an
AST tool doesn't save any of the hard work below. A single state-aware
scanner that treats "code" and "comment" text uniformly once real
strings/chars/text-blocks have been carved out is simpler and easier to
reason about end-to-end.
Algorithm, in two layers:
1. Segmentation pass (single left-to-right scan of the whole file):
classify every character into one of CODE / LINE_COMMENT /
BLOCK_COMMENT (this includes Javadoc, since `/**` is just a block
comment) / STRING / CHAR / TEXT_BLOCK, honoring `\\` escapes inside
string/char literals and triple-quote text-block delimiters. This is
the part that guarantees we never touch a comma inside a string
literal or text block (rule #3 in the task boundaries).
2. Generic-span pass (applied independently to each CODE and
LINE_COMMENT/BLOCK_COMMENT segment; STRING/CHAR/TEXT_BLOCK segments
are copied through verbatim): scan for `<`, then tentatively walk
forward maintaining an angle-bracket nesting depth, accepting only
characters that can legally appear inside a Java type-argument list
(identifier characters, `.`, `?`, `[`, `]`, whitespace, `,`, nested
`<`/`>`, and a lone `&` for intersection bounds -- `&&` bails). The
walk fails closed: the first character outside that set (`(`, `)`,
`;`, `=`, `"`, `&&`, etc.) aborts the match and the `<` is emitted
literally, so ordinary comparisons/shifts (`a < b`, `x >> 3`),
method-argument lists, annotation element lists, and HTML markup in
Javadoc (`<p>`, `<a href="...">`, `</code>`) are left untouched --
they either never reach a matching top-level `>`, or they reach one
with no comma inside to rewrite anyway. `>>`/`>>>` naturally fall
out of the depth-counting (each `>` closes exactly one level) rather
than needing special-casing as shift-operator tokens. Bounded
wildcards (`? extends Foo`, `? super Bar`) survive untouched because
only `,[ \\t]+` sequences are collapsed to `,` -- nothing else in the
matched span is rewritten.
Known, accepted false-negative (never false-positive) gaps -- see the
prototype report for the reasoning:
- A generic span that is itself interrupted by a `//` or `/* */`
comment (e.g. `Map<String, // key type\\n Object>`) will fail to
match (the segmentation pass has already cut the span into two
pieces) and is left unrewritten.
- Type-use annotations inside a type-argument list (`List<@NonNull
String>`) are not in the allowed character set and are left
unrewritten.
Both are misses, not corruptions: the script never rewrites a comma
outside a validated `<...>` span.
One real over-match was found and fixed during the reactor-wide run:
prose/comments enumerating comparison-operator examples (e.g. a Javadoc
comment listing `<123, >=123, <=123, ...` as sample query-syntax
tokens) are pure digits/punctuation with no letters, which satisfied
the depth-and-charset walk (the first `>` closed a "span" whose content
was just digits, a comma, and whitespace) even though it is not a type
list at all. Fix: a matched span must contain at least one letter or
`?` somewhere in it, since every real Java type reference is either an
identifier (which starts with a letter/`_`/`$`) or a bare `?` wildcard
-- a span of only digits/punctuation/whitespace can never be a genuine
type-argument list and is now rejected (bailed, i.e. left unrewritten).
Usage:
python3 scripts/normalize-generic-commas.py <path> [<path> ...]
python3 scripts/normalize-generic-commas.py --check <path> [...] # dry run, exit 1 if changes needed
<path> may be a .java file or a directory (walked recursively, minus
target/ and .git/).
"""
import re
import sys
from pathlib import Path
# Characters that may legally appear inside a Java type-argument list.
# NOTE: '&' is handled separately (single '&' allowed, '&&' rejected).
_ALLOWED_CHARS = set(
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
'_$.?[] \t\r\n,'
)
# Safety cap: a genuine type-argument list is never anywhere near this
# long. Bail rather than let a pathological run of allowed characters
# (e.g. a long prose sentence with lots of commas and no punctuation)
# swallow half a file.
_MAX_SPAN = 2000
_COMMA_SPACE_RE = re.compile(r',[ \t]+')
_HAS_LETTER_OR_WILDCARD_RE = re.compile(r'[A-Za-z?]')
def _find_generic_end(text, pos):
"""
text[pos] == '<'. Try to find the index just past the matching
top-level '>' for a "generic-shaped" span starting at pos, walking
forward and tracking nesting depth. Returns that index, or None if
the span is not generic-shaped (contains a disallowed character,
an '&&', runs off the end of the segment, exceeds _MAX_SPAN, or has
no letter/'?' anywhere in it -- every real type reference is an
identifier or a wildcard, so a span of pure digits/punctuation,
e.g. a comment enumerating comparison operators like "<123, >=123",
can never be a genuine type-argument list).
"""
n = len(text)
depth = 0
i = pos
while i < n:
if i - pos > _MAX_SPAN:
return None
c = text[i]
if c == '<':
depth += 1
i += 1
continue
if c == '>':
depth -= 1
i += 1
if depth == 0:
if not _HAS_LETTER_OR_WILDCARD_RE.search(text[pos:i]):
return None
return i
continue
if c == '&':
prev_amp = i > pos + 1 and text[i - 1] == '&'
next_amp = i + 1 < n and text[i + 1] == '&'
if prev_amp or next_amp:
return None
i += 1
continue
if c in _ALLOWED_CHARS:
i += 1
continue
return None
return None
def tighten_generic_commas(text):
"""
Scan `text` for generic-shaped `<...>` spans and collapse
`,<space/tab>+` to `,` inside them (at every nesting level, in one
shot, since a validated span never contains anything but
type-argument-list characters). Returns (new_text, num_commas_changed).
"""
out = []
i = 0
n = len(text)
changed = 0
while i < n:
c = text[i]
if c == '<':
end = _find_generic_end(text, i)
if end is not None:
span = text[i:end]
new_span, span_changed = _COMMA_SPACE_RE.subn(',', span)
out.append(new_span)
changed += span_changed
i = end
continue
out.append(c)
i += 1
return ''.join(out), changed
# ---------------------------------------------------------------------------
# Segmentation: split a whole .java file into (kind, text) segments so the
# generic-span pass above can be applied only to CODE / comment text, never
# to string/char/text-block contents.
# ---------------------------------------------------------------------------
CODE = 'CODE'
LINE_COMMENT = 'LINE_COMMENT'
BLOCK_COMMENT = 'BLOCK_COMMENT'
STRING = 'STRING'
CHAR = 'CHAR'
TEXT_BLOCK = 'TEXT_BLOCK'
_REWRITE_KINDS = (CODE, LINE_COMMENT, BLOCK_COMMENT)
def _scan_escaped_literal(text, pos, quote):
"""
text[pos] == quote (a single `"` or `'`). Return the index just past
the matching unescaped closing quote, honoring `\\` escapes. If the
literal is unterminated before a newline or EOF, returns the index
of that newline/EOF (best-effort; malformed input is left as-is).
"""
n = len(text)
i = pos + 1
while i < n:
c = text[i]
if c == '\\':
i += 2
continue
if c == quote:
return i + 1
if c == '\n':
return i
i += 1
return i
def _scan_text_block(text, pos):
"""
text[pos:pos+3] == '\"\"\"'. Return the index just past the closing
'\"\"\"' delimiter, honoring a single preceding backslash-escape of
the closing delimiter. Falls back to EOF if unterminated.
"""
n = len(text)
i = pos + 3
while i < n:
if text[i] == '"' and text[i:i + 3] == '"""':
# An odd number of immediately-preceding backslashes means
# the first quote of this run is escaped; skip just past it
# and keep looking for a real delimiter.
backslashes = 0
j = i - 1
while j >= 0 and text[j] == '\\':
backslashes += 1
j -= 1
if backslashes % 2 == 1:
i += 1
continue
return i + 3
i += 1
return n
def segment_java_source(text): # NOSONAR python:S3776 -- state-machine tokenizer; splitting further would obscure the transitions
"""
Split `text` into a list of (kind, text) tuples covering the entire
file, classifying each stretch as CODE, LINE_COMMENT, BLOCK_COMMENT
(Javadoc included), STRING, CHAR, or TEXT_BLOCK.
"""
segments = []
n = len(text)
i = 0
start = 0
def flush(kind, end):
if end > start:
segments.append((kind, text[start:end]))
while i < n:
c = text[i]
if c == '/' and i + 1 < n and text[i + 1] == '/':
flush(CODE, i)
j = text.find('\n', i)
j = n if j == -1 else j
segments.append((LINE_COMMENT, text[i:j]))
i = j
start = i
continue
if c == '/' and i + 1 < n and text[i + 1] == '*':
flush(CODE, i)
j = text.find('*/', i + 2)
j = n if j == -1 else j + 2
segments.append((BLOCK_COMMENT, text[i:j]))
i = j
start = i
continue
if c == '"' and text[i:i + 3] == '"""':
flush(CODE, i)
j = _scan_text_block(text, i)
segments.append((TEXT_BLOCK, text[i:j]))
i = j
start = i
continue
if c == '"':
flush(CODE, i)
j = _scan_escaped_literal(text, i, '"')
segments.append((STRING, text[i:j]))
i = j
start = i
continue
if c == "'":
flush(CODE, i)
j = _scan_escaped_literal(text, i, "'")
segments.append((CHAR, text[i:j]))
i = j
start = i
continue
i += 1
flush(CODE, n)
return segments
def transform_source(text):
"""
Apply the generic-comma-tightening transform to a whole file's
source text. Returns (new_text, code_changed, comment_changed).
"""
segments = segment_java_source(text)
out = []
code_changed = 0
comment_changed = 0
for kind, seg_text in segments:
if kind in _REWRITE_KINDS:
new_seg, n_changed = tighten_generic_commas(seg_text)
out.append(new_seg)
if kind == CODE:
code_changed += n_changed
else:
comment_changed += n_changed
else:
out.append(seg_text)
return ''.join(out), code_changed, comment_changed
def find_java_files(root_dir):
root_path = Path(root_dir)
if root_path.is_file():
return [root_path]
java_files = []
for java_file in root_path.rglob('*.java'):
if 'target' in java_file.parts or '.git' in java_file.parts:
continue
java_files.append(java_file)
return sorted(java_files)
def main():
args = sys.argv[1:]
check_only = False
if args and args[0] == '--check':
check_only = True
args = args[1:]
if not args:
print(__doc__)
return 1
all_files = []
for arg in args:
all_files.extend(find_java_files(arg))
files_changed = 0
total_code_sites = 0
total_comment_sites = 0
needs_change = False
for path in all_files:
original = path.read_text(encoding='utf-8')
new_text, code_n, comment_n = transform_source(original)
if new_text != original:
needs_change = True
files_changed += 1
total_code_sites += code_n
total_comment_sites += comment_n
if check_only:
print(f"WOULD CHANGE: {path} (code={code_n}, comment={comment_n})")
else:
path.write_text(new_text, encoding='utf-8')
print(f"Changed: {path} (code={code_n}, comment={comment_n})")
print("\nSummary:")
print(f" Files scanned: {len(all_files)}")
print(f" Files changed: {files_changed}")
print(f" Comma sites tightened in code: {total_code_sites}")
print(f" Comma sites tightened in comments: {total_comment_sites}")
if check_only and needs_change:
return 1
return 0
if __name__ == '__main__':
sys.exit(main())