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
17 changes: 15 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import dev.sorn.fmp4j.CoveragePlugin
import dev.sorn.fmp4j.SnakeCaseMethodFormatter

plugins {
id 'java-library'
id 'java-test-fixtures'
id 'checkstyle'
id 'jacoco'
id 'com.vanniktech.maven.publish' version '0.34.0'
id 'com.diffplug.spotless' version '7.2.0'
id 'com.diffplug.spotless'
}

apply plugin: dev.sorn.fmp4j.CoveragePlugin
apply plugin: CoveragePlugin

group = 'dev.sorn.fmp4j'
version = projectVersion
Expand All @@ -18,6 +21,14 @@ java {
}
}

spotless {
java {
googleJavaFormat()
removeUnusedImports()
addStep(new SnakeCaseMethodFormatter())
}
}

repositories {
mavenCentral()
}
Expand All @@ -28,6 +39,8 @@ dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}")
implementation("org.apache.httpcomponents.client5:httpclient5:${apacheHttpComponentsVersion}")
implementation("org.apache.commons:commons-lang3:${apacheCommonsVersion}")
implementation("com.diffplug.spotless:spotless-plugin-gradle:${spotlessVersion}")

testImplementation("org.mockito:mockito-core:${mockitoCoreVersion}")
testImplementation("org.mockito:mockito-junit-jupiter:${mockitoJunitVersion}")
testImplementation(platform("org.junit:junit-bom:${junitVersion}"))
Expand Down
11 changes: 11 additions & 0 deletions buildSrc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,15 @@ repositories {
dependencies {
implementation gradleApi()
implementation localGroovy()
implementation("com.diffplug.spotless:spotless-plugin-gradle:${spotlessVersion}")
implementation("com.github.javaparser:javaparser-core:${javaparserVersion}")

testImplementation(platform("org.junit:junit-bom:${junitVersion}"))
testImplementation("org.junit.jupiter:junit-jupiter-api")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

test {
useJUnitPlatform()
}
3 changes: 3 additions & 0 deletions buildSrc/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
spotlessVersion=7.2.0
javaparserVersion=3.27.0
junitVersion=5.10.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package dev.sorn.fmp4j;

import com.diffplug.spotless.FormatterStep;
import java.io.File;
import java.io.Serial;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;


public class SnakeCaseMethodFormatter implements FormatterStep {
@Serial
private static final long serialVersionUID = 1L;

// Set of annotations that identify a method as a unit test
private static final Pattern TEST_METHOD_PATTERN = Pattern.compile(
"(@(?:Test|ParameterizedTest|RepeatedTest|TestFactory)(?:[\\s\\S](?!;|\\{))*?\\s+)([a-zA-Z0-9_]+)(\\s*\\()"
);

private static final Pattern SNAKE_CASE_PATTERN = Pattern.compile("^[a-z][a-z0-9_]*$");

@Override
public String getName() {
return "Unit test Naming Enforcement (snake_case)";
}

@Override
public String format(String rawUnix, File file) throws Exception {
Matcher matcher = TEST_METHOD_PATTERN.matcher(rawUnix);
StringBuilder sb = new StringBuilder();

while (matcher.find()) {
String context = matcher.group(1); // Annotation + modifiers + return type
String methodName = matcher.group(2); // Current method name
String paramsStart = matcher.group(3); // "("

// Only replace if it fails snake_case check
if (!SNAKE_CASE_PATTERN.matcher(methodName).matches()) {
String newName = toSnakeCase(methodName);
// Replace the name group, preserving context and params
matcher.appendReplacement(sb, Matcher.quoteReplacement(context + newName + paramsStart));
}
}
matcher.appendTail(sb);

return sb.toString();
}

/**
* Converts a camelCase string to snake_case.
* Example: "testUserProfile" -> "test_user_profile"
*/
private String toSnakeCase(String input) {
// Regex to look for instances of LowerUpper (e.g. "tU") and insert underscore
String regex = "([a-z])([A-Z]+)";
String replacement = "$1_$2";

// Apply replacement and convert to lowercase
return input.replaceAll(regex, replacement).toLowerCase(Locale.ROOT);
}

@Override
public void close() throws Exception {
// No resources to close
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package dev.sorn.fmp4j;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.File;


class SnakeCaseMethodFormatterTest {

private SnakeCaseMethodFormatter formatter;

@BeforeEach
void setUp() {
formatter = new SnakeCaseMethodFormatter();
}

@Test
void should_rename_camel_case_unit_test_to_snake_case() throws Exception {
// Given: A class with a camelCase @Test method
String source = """
public class MyTest {
@Test
public void testLoginFeature() {
System.out.println("Testing...");
}
}
""";

// When
String result = formatter.format(source, new File("dummy.java"));

// Then
assertTrue(result.contains("void test_login_feature()"),
"The method name should be converted to snake_case");
assertFalse(result.contains("testLoginFeature"),
"The old camelCase name should no longer exist");
}

@Test
void should_not_rename_non_test_methods() throws Exception {
// Given: A class with a helper method (no @Test annotation)
String source = """
public class MyTest {
public void helperMethod() {
// do something
}
}
""";

// When
String result = formatter.format(source, null);

// Then
assertTrue(result.contains("void helperMethod()"),
"Non-test methods should retain their original name");
}

@Test
void should_ignore_already_snake_case_tests() throws Exception {
// Given: A class with an existing snake_case test
String source = """
public class MyTest {
@Test
public void test_existing_snake_case() {
}
}
""";

// When
String result = formatter.format(source, null);

// Then: The method returns the original string if no changes were made
assertEquals(source, result,
"The source code should remain unchanged if the name is already correct");
}

@Test
void should_handle_mixed_methods_correctly() throws Exception {
// Given: One test to rename, one helper to ignore, one test already correct
String source = """
public class MixedTest {
@Test
public void shouldCalculateSum() {}

public void setupDatabase() {}

@Test
public void test_already_good() {}
}
""";

// When
String result = formatter.format(source, null);

// Then
assertAll("Verify all method states",
() -> assertTrue(result.contains("void should_calculate_sum()"), "CamelCase test should be renamed"),
() -> assertTrue(result.contains("void setupDatabase()"), "Helper method should not change"),
() -> assertTrue(result.contains("void test_already_good()"), "Snake case test should stay snake case")
);
}

@Test
void should_handle_complex_test_annotations() throws Exception {
// Given: One test to rename, one helper to ignore, one test already correct
String source = """
public class MixedTest {
@ParameterizedTest
@MethodSource("someFactoryMethod")
void validValue() {
//do something
}
}
""";

// When
String result = formatter.format(source, null);

// Then
assertAll("Verify all method states",
() -> assertTrue(result.contains("void valid_value()"), "CamelCase test should be renamed"),
() -> assertTrue(result.contains("@ParameterizedTest"), "Annotation should not be moved"),
() -> assertTrue(result.contains("@MethodSource(\"someFactoryMethod\")"), "Annotation should not be moved")
);
}

@Test
void should_handle_inline_comment() throws Exception {
// Given: One test to rename, one helper to ignore, one test already correct
String source = """
class InlineCommentTest {
@Test
void doSomethingHere() {
Stream.of(
"" // empty string
);
}
}
""";

// When
String result = formatter.format(source, null);

// Then
assertAll("Verify all method states",
() -> assertTrue(result.contains("do_something_here"), "CamelCase test should be renamed"),
() -> assertTrue(result.contains("\"\" // empty string"), "Inline comment should remain in place")
);
}
}

1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ apacheCommonsVersion=3.18.0
apacheHttpComponentsVersion=5.5
jacksonVersion=2.19.2
jacksonCsvVersion=2.17.2
spotlessVersion=7.2.0

# Publishing
mavenCentralPublishing=true
Expand Down
9 changes: 7 additions & 2 deletions src/main/java/dev/sorn/fmp4j/types/FmpSymbol.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ public final class FmpSymbol implements Comparable<FmpSymbol>, FmpValueObject<St
// 5. OR
// 6. Dot separator followed by segment (1-4 characters, starting with letter)
// 7. End group, repeated zero or more times
public static final Pattern FMP_SYMBOL_PATTERN = compile("^(?:[A-Z0-9]{1,5}:)?" + "[A-Z0-9&]{1,16}" + "(?:"
+ "(?:[-/][A-Z][A-Z0-9]{0,9})" + "|" + "(?:\\.[A-Z][A-Z0-9]{0,3})" + ")*$");
public static final Pattern FMP_SYMBOL_PATTERN = compile("^(?:[A-Z0-9]{1,5}:)?"
+ "[A-Z0-9&]{1,16}"
+ "(?:"
+ "(?:[-/][A-Z][A-Z0-9]{0,9})"
+ "|"
+ "(?:\\.[A-Z][A-Z0-9]{0,3})"
+ ")*$");

@Serial
private static final long serialVersionUID = 1L;
Expand Down
Loading
Loading