diff --git a/src/main/java/liquidjava/fsm/StateMachineParser.java b/src/main/java/liquidjava/fsm/StateMachineParser.java index 98039dd..59f1ea5 100644 --- a/src/main/java/liquidjava/fsm/StateMachineParser.java +++ b/src/main/java/liquidjava/fsm/StateMachineParser.java @@ -24,6 +24,7 @@ public class StateMachineParser { private static final String STATE_SET_ANNOTATION = "StateSet"; private static final String STATE_REFINEMENT_ANNOTATION = "StateRefinement"; + private static final String REFINEMENT_ANNOTATION = "Refinement"; private static final String EXTERNAL_REFINEMENTS_FOR_ANNOTATION = "ExternalRefinementsFor"; private record TransitionSource(String from, String cond) {} @@ -66,10 +67,14 @@ private static StateMachine parse(String uri, SourcePosition declarationPosition String simpleClassName = Utils.getSimpleName(className); // get initial transitions and method transitions + List factoryTransitions = getFactoryTransitions(ctType, simpleClassName, states); List initialTransitions = getInitialTransitions(ctType, simpleClassName, states); - if (initialTransitions.isEmpty()) { + // interfaces only have an implicit initial state when there are no factory methods + if (initialTransitions.isEmpty() && (ctType instanceof CtClass || factoryTransitions.isEmpty())) { initialTransitions = getDefaultInitialTransitions(ctType, simpleClassName, states.get(0)); } + initialTransitions = new ArrayList<>(initialTransitions); + initialTransitions.addAll(factoryTransitions); List transitions = getTransitions(ctType, simpleClassName, states); if (transitions.isEmpty()) return null; // no transitions found @@ -219,9 +224,39 @@ private static List getDefaultInitialTransitions( } private static String getConstructorSignature(CtExecutable element, String className) { - return "new " + className + "(" + String.join(", ", element.getParameters().stream() + return "new " + className + "(" + getParameterTypes(element) + ")"; + } + + /** + * Gets the initial transitions from static factory methods (methods that return a new object in a given state) + * For classes these must be static, for interfaces any method returning the class type is considered + * @param ctType the CtType (class or interface) + * @param className the class name + * @param states the list of states + * @return initial transitions + */ + private static List getFactoryTransitions(CtType ctType, String className, List states) { + List initialTransitions = new ArrayList<>(); + for (CtMethod method : ctType.getMethods()) { + if (ctType.isClass() && !method.isStatic()) + continue; + if (method.getType() == null || !method.getType().getSimpleName().equals(className)) + continue; + String signature = className + "." + method.getSimpleName() + "(" + getParameterTypes(method) + ")"; + for (CtAnnotation annotation : method.getAnnotations()) { + if (annotation.getAnnotationType().getSimpleName().equals(REFINEMENT_ANNOTATION)) { + String refinement = annotation.getValueAsString("value"); + initialTransitions.addAll(parseInitialTransitions(refinement, states, signature)); + } + } + } + return initialTransitions; + } + + private static String getParameterTypes(CtExecutable element) { + return String.join(", ", element.getParameters().stream() .map(parameter -> parameter.getType() == null ? "?" : parameter.getType().getSimpleName()) - .toList()) + ")"; + .toList()); } /** diff --git a/src/test/java/liquidjava/fsm/StateMachineParserTests.java b/src/test/java/liquidjava/fsm/StateMachineParserTests.java index 2ae3f01..00eb744 100644 --- a/src/test/java/liquidjava/fsm/StateMachineParserTests.java +++ b/src/test/java/liquidjava/fsm/StateMachineParserTests.java @@ -226,6 +226,47 @@ public void testGuardedSelfLoopDoesNotDuplicateCondition() { assertStateMachineEquals(expectedSm, sm); } + @Test + public void testStaticFactoryMethods() { + // static methods returning the class are initial transitions + StateMachine sm = StateMachineParser.parse(uri("StaticFactory.java")); + StateMachine expectedSm = stateMachine("StaticFactory", List.of("open", "closed"), + List.of(new StateMachineTransition("open", "closed", "close")), + List.of(new StateMachineInitialTransition("open"), + new StateMachineInitialTransition("closed"), + new StateMachineInitialTransition("open", "flag"), + new StateMachineInitialTransition("closed", "!flag"))); + assertStateMachineEquals(expectedSm, sm); + assertEquals(List.of("new StaticFactory()", "StaticFactory.closedInstance()", + "StaticFactory.of(boolean)", "StaticFactory.of(boolean)"), + sm.initialTransitions().stream().map(StateMachineInitialTransition::constructorSignature).toList()); + } + + @Test + public void testExternalStaticFactoryMethods() { + // in interfaces any method returning the class is a factory method + StateMachine sm = StateMachineParser.parse(uri("ExternalStaticFactory.java")); + StateMachine expectedSm = stateMachine("java.util.UUID", + List.of("dceSecurityNameRandom", "maybeTime", "timeBased"), + List.of(new StateMachineTransition("timeBased", "timeBased", "timestamp")), + List.of(new StateMachineInitialTransition("timeBased", "mostSigBits / 4096 % 16 == 1"), + new StateMachineInitialTransition("dceSecurityNameRandom", "!(mostSigBits / 4096 % 16 == 1)"), + new StateMachineInitialTransition("maybeTime"), + new StateMachineInitialTransition("dceSecurityNameRandom"))); + assertStateMachineEquals(expectedSm, sm); + assertEquals(List.of("new UUID(long, long)", "new UUID(long, long)", "UUID.fromString(String)", + "UUID.randomUUID()"), + sm.initialTransitions().stream().map(StateMachineInitialTransition::constructorSignature).toList()); + } + + @Test + public void testExternalFactoryWithoutConstructorHasNoDefaultInitialState() { + StateMachine sm = StateMachineParser.parse(uri("ExternalFactoryOnly.java")); + assertNotNull(sm); + assertEquals(List.of(new StateMachineInitialTransition("timeBased", null, "UUID.fromString(String)")), + sm.initialTransitions()); + } + private static void assertStateMachineEquals(StateMachine expected, StateMachine actual) { assertNotNull(actual, "State machine should not be null"); assertEquals(expected.className(), actual.className(), "Class names should match"); diff --git a/src/test/resources/fsm/ExternalFactoryOnly.java b/src/test/resources/fsm/ExternalFactoryOnly.java new file mode 100644 index 0000000..109a643 --- /dev/null +++ b/src/test/resources/fsm/ExternalFactoryOnly.java @@ -0,0 +1,19 @@ +package fsm; + +import java.util.UUID; + +import liquidjava.specification.ExternalRefinementsFor; +import liquidjava.specification.Refinement; +import liquidjava.specification.StateRefinement; +import liquidjava.specification.StateSet; + +@ExternalRefinementsFor("java.util.UUID") +@StateSet({"dceSecurityNameRandom", "timeBased"}) +public interface ExternalFactoryOnly { + + @Refinement("timeBased(return)") + UUID fromString(String name); + + @StateRefinement(from="timeBased(this)") + long timestamp(); +} diff --git a/src/test/resources/fsm/ExternalStaticFactory.java b/src/test/resources/fsm/ExternalStaticFactory.java new file mode 100644 index 0000000..a62f3ab --- /dev/null +++ b/src/test/resources/fsm/ExternalStaticFactory.java @@ -0,0 +1,25 @@ +package fsm; + +import java.util.UUID; + +import liquidjava.specification.ExternalRefinementsFor; +import liquidjava.specification.Refinement; +import liquidjava.specification.StateRefinement; +import liquidjava.specification.StateSet; + +@ExternalRefinementsFor("java.util.UUID") +@StateSet({"dceSecurityNameRandom", "maybeTime", "timeBased"}) +public interface ExternalStaticFactory { + + @StateRefinement(to="((mostSigBits/4096) % 16 == 1) ? timeBased(this) : dceSecurityNameRandom(this)") + void UUID(long mostSigBits, long leastSigBits); + + @Refinement("maybeTime(return)") + UUID fromString(String name); + + @Refinement("dceSecurityNameRandom(return)") + UUID randomUUID(); + + @StateRefinement(from="timeBased(this)") + long timestamp(); +} diff --git a/src/test/resources/fsm/StaticFactory.java b/src/test/resources/fsm/StaticFactory.java new file mode 100644 index 0000000..01aefa7 --- /dev/null +++ b/src/test/resources/fsm/StaticFactory.java @@ -0,0 +1,30 @@ +package fsm; + +import liquidjava.specification.Refinement; +import liquidjava.specification.StateRefinement; +import liquidjava.specification.StateSet; + +@StateSet({"open", "closed"}) +public class StaticFactory { + + @StateRefinement(to="open(this)") + public StaticFactory() {} + + // static factory methods are initial transitions + @Refinement("closed(return)") + public static StaticFactory closedInstance() { return null; } + + @Refinement("flag ? open(return) : closed(return)") + public static StaticFactory of(boolean flag) { return null; } + + // ignored: not static + @Refinement("closed(return)") + public StaticFactory copy() { return null; } + + // ignored: does not return the class type + @Refinement("_ > 0") + public static int count() { return 1; } + + @StateRefinement(from="open(this)", to="closed(this)") + public void close() {} +}