Skip to content
Draft
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
78 changes: 78 additions & 0 deletions jspecify_migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import os
import re

def migrate_file(file_path):
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()

original_content = content

# 1. Add JSpecify NullMarked import if not present
package_match = re.search(r"^(package\s+[\w\.]+;)", content, re.MULTILINE)
if package_match and "org.jspecify.annotations.NullMarked" not in content and "@NullMarked" not in content:
package_line = package_match.group(1)
content = content.replace(package_line, package_line + "\nimport org.jspecify.annotations.NullMarked;")
Comment on lines +11 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using content.replace(package_line, ...) can lead to accidental replacements if the package declaration string appears elsewhere in the file (e.g., in comments or string literals). Additionally, the package regex can be made more robust to handle spaces or comments before the semicolon.

Using the match's end position (package_match.end()) to perform a slice-based insertion is much safer.

Suggested change
package_match = re.search(r"^(package\s+[\w\.]+;)", content, re.MULTILINE)
if package_match and "org.jspecify.annotations.NullMarked" not in content and "@NullMarked" not in content:
package_line = package_match.group(1)
content = content.replace(package_line, package_line + "\nimport org.jspecify.annotations.NullMarked;")
package_match = re.search(r"^\s*package\s+[\w\.]+\s*;", content, re.MULTILINE)
if package_match and "org.jspecify.annotations.NullMarked" not in content and "@NullMarked" not in content:
end_pos = package_match.end()
content = content[:end_pos] + "\nimport org.jspecify.annotations.NullMarked;" + content[end_pos:]


# 2. Add @NullMarked before class/interface/enum declaration
class_match = re.search(r"\n(public\s+)?(abstract\s+)?(final\s+)?(class|interface|enum)\s+\w+", content)
if class_match:
class_start = class_match.start()
prefix = content[:class_start]
lines = prefix.split("\n")

# Find the correct insertion point (before other class-level annotations)
insert_idx = len(lines)
for i in range(len(lines) - 1, -1, -1):
line = lines[i].strip()
if line.startswith("@") and not line.startswith("@Override") and not line.startswith("@Test"):
insert_idx = i
elif line == "" or line.startswith("//") or line.startswith("/*") or line.startswith("*"):
break
else:
break
lines.insert(insert_idx, "@NullMarked")
content = "\n".join(lines) + content[class_start:]
Comment on lines +17 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current class-matching regex has a few limitations:

  1. It misses several common modifiers such as private, protected, static, sealed, and non-sealed.
  2. It does not support Java 14+ record types.
  3. It fails to match class declarations at the very beginning of a file (where there is no leading newline).

We can make this regex much more robust by using (\n|^) to support the start of the file, and matching all standard Java modifiers and type declarations (including record).

        class_match = re.search(r"(\n|^)\s*(?:(?:public|protected|private|abstract|final|static|sealed|non-sealed)\s+)*(?:class|interface|enum|record)\s+\w+", content)
        if class_match:
            class_start = class_match.start()
            if class_start == 0:
                content = "@NullMarked\n" + content
            else:
                prefix = content[:class_start]
                lines = prefix.split("\n")
                
                # Find the correct insertion point (before other class-level annotations)
                insert_idx = len(lines)
                for i in range(len(lines) - 1, -1, -1):
                    line = lines[i].strip()
                    if line.startswith("@") and not line.startswith("@Override") and not line.startswith("@Test"):
                        insert_idx = i
                    elif line == "" or line.startswith("//") or line.startswith("/*") or line.startswith("*"):
                        break
                    else:
                        break
                lines.insert(insert_idx, "@NullMarked")
                content = "\n".join(lines) + content[class_start:]



# 3. Replace legacy javax Nullable import
content = content.replace("import javax.annotation.Nullable;", "import org.jspecify.annotations.Nullable;")

# 4. Reposition @Nullable from declaration to type-use position
# (immediately before the type name, after class modifiers)
patterns = [
(r"@Nullable\s+public\s+abstract\s+final\s+(\w+)", r"public abstract final @Nullable \1"),
(r"@Nullable\s+public\s+abstract\s+(\w+)", r"public abstract @Nullable \1"),
(r"@Nullable\s+public\s+static\s+final\s+(\w+)", r"public static final @Nullable \1"),
(r"@Nullable\s+public\s+static\s+(\w+)", r"public static @Nullable \1"),
(r"@Nullable\s+public\s+final\s+(\w+)", r"public final @Nullable \1"),
(r"@Nullable\s+public\s+(\w+)", r"public @Nullable \1"),
(r"@Nullable\s+private\s+static\s+final\s+(\w+)", r"private static final @Nullable \1"),
(r"@Nullable\s+private\s+static\s+(\w+)", r"private static @Nullable \1"),
(r"@Nullable\s+private\s+final\s+(\w+)", r"private final @Nullable \1"),
(r"@Nullable\s+private\s+(\w+)", r"private @Nullable \1"),
(r"@Nullable\s+protected\s+static\s+final\s+(\w+)", r"protected static final @Nullable \1"),
(r"@Nullable\s+protected\s+static\s+(\w+)", r"protected static @Nullable \1"),
(r"@Nullable\s+protected\s+final\s+(\w+)", r"protected final @Nullable \1"),
(r"@Nullable\s+protected\s+(\w+)", r"protected @Nullable \1"),
]

for pattern, replacement in patterns:
content = re.sub(pattern, replacement, content)
Comment on lines +42 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instead of maintaining 14 separate hardcoded regex patterns, we can simplify this to a single, highly robust regex.

This simplification has several major benefits:

  1. It automatically supports any combination/order of modifiers.
  2. It supports package-private members, parameters, and local variables (which don't have access modifiers but might have other modifiers like final or static).
  3. It avoids issues with complex generic types or type parameters (e.g., <T>) because it only matches the modifiers and repositions @Nullable right after them, without needing to match the type name itself.
    # Reposition @Nullable to be after all modifiers (type-use position)
    # This single regex handles any combination of modifiers (including package-private, final parameters, etc.)
    modifier_pattern = r"@Nullable\s+((?:(?:public|private|protected|static|final|abstract|synchronized|volatile|transient|native|strictfp|sealed|non-sealed)\s+)+)"
    content = re.sub(modifier_pattern, r"\1@Nullable ", content)


# Save changes if modified
if content != original_content:
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)

def walk_and_migrate(directory):
for root, _, files in os.walk(directory):
for file in files:
if file.endswith(".java") and file != "package-info.java":
migrate_file(os.path.join(root, file))

if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
walk_and_migrate(sys.argv[1])
else:
print("Usage: python jspecify_migration.py <directory_path>")
Loading