001package arez.persist.processor;
002
003import java.io.IOException;
004import java.util.ArrayList;
005import java.util.Collection;
006import java.util.HashMap;
007import java.util.List;
008import java.util.Map;
009import java.util.Set;
010import java.util.regex.Matcher;
011import java.util.regex.Pattern;
012import javax.annotation.Nonnull;
013import javax.annotation.Nullable;
014import javax.annotation.processing.RoundEnvironment;
015import javax.annotation.processing.SupportedAnnotationTypes;
016import javax.annotation.processing.SupportedOptions;
017import javax.annotation.processing.SupportedSourceVersion;
018import javax.lang.model.SourceVersion;
019import javax.lang.model.element.AnnotationMirror;
020import javax.lang.model.element.Element;
021import javax.lang.model.element.ElementKind;
022import javax.lang.model.element.ExecutableElement;
023import javax.lang.model.element.TypeElement;
024import javax.lang.model.type.TypeKind;
025import javax.lang.model.type.TypeMirror;
026import org.realityforge.proton.AbstractStandardProcessor;
027import org.realityforge.proton.AnnotationsUtil;
028import org.realityforge.proton.DeferredElementSet;
029import org.realityforge.proton.ElementsUtil;
030import org.realityforge.proton.GeneratorUtil;
031import org.realityforge.proton.MemberChecks;
032import org.realityforge.proton.ProcessorException;
033import org.realityforge.proton.StopWatch;
034
035/**
036 * Annotation processor that analyzes Arez annotated source and generates models from the annotations.
037 */
038@SupportedAnnotationTypes( { Constants.PERSIST_TYPE_CLASSNAME,
039                             Constants.PERSIST_ID_CLASSNAME,
040                             Constants.PERSIST_CLASSNAME } )
041@SupportedSourceVersion( SourceVersion.RELEASE_17 )
042@SupportedOptions( { "arez.persist.defer.unresolved",
043                     "arez.persist.defer.errors",
044                     "arez.persist.debug",
045                     "arez.persist.format_generated_source",
046                     "arez.persist.profile",
047                     "arez.persist.verbose_out_of_round",
048                     "arez.persist.warnings_as_errors" } )
049public final class ArezPersistProcessor
050  extends AbstractStandardProcessor
051{
052  @Nonnull
053  private static final Pattern GETTER_PATTERN = Pattern.compile( "^get([A-Z].*)$" );
054  @Nonnull
055  private static final Pattern ISSER_PATTERN = Pattern.compile( "^is([A-Z].*)$" );
056  /**
057   * Sentinel value indicating the default value.
058   */
059  @Nonnull
060  private static final String DEFAULT_SENTINEL = "<default>";
061  @Nonnull
062  private final DeferredElementSet _deferredTypes = new DeferredElementSet();
063  @Nonnull
064  private final StopWatch _analyzePersistTypeStopWatch = new StopWatch( "Analyze PersistType" );
065  @Nonnull
066  private final StopWatch _analyzePersistIdStopWatch = new StopWatch( "Analyze PersistId" );
067
068  @Override
069  @Nonnull
070  protected String getIssueTrackerURL()
071  {
072    return "https://github.com/arez/arez/issues";
073  }
074
075  @Nonnull
076  @Override
077  protected String getOptionPrefix()
078  {
079    return "arez.persist";
080  }
081
082  @Override
083  protected void collectStopWatches( @Nonnull final Collection<StopWatch> stopWatches )
084  {
085    stopWatches.add( _analyzePersistTypeStopWatch );
086    stopWatches.add( _analyzePersistIdStopWatch );
087  }
088
089  @Override
090  public boolean process( @Nonnull final Set<? extends TypeElement> annotations, @Nonnull final RoundEnvironment env )
091  {
092    debugAnnotationProcessingRootElements( env );
093    collectRootTypeNames( env );
094    // Validate persist first so we don't have to perform validation inside type processing
095    // The framework assumes that Arez is also running the annotation processor and validating
096    // the shape of the same methods as they are marked as @Observable so this annotation processor
097    // performs minimal validation. This can result in this annotation processor producing bad code
098    // and the arez annotation processor failing later in the round. The expectation is that we will
099    // need to get the arez annotation processor passing and once it does then the code generated by
100    // this annotation processor will start to work.
101    annotations
102      .stream()
103      .filter( a -> a.getQualifiedName().toString().equals( Constants.PERSIST_CLASSNAME ) )
104      .findAny()
105      .ifPresent( a -> verifyPersistElements( env, env.getElementsAnnotatedWith( a ) ) );
106
107    annotations
108      .stream()
109      .filter( a -> a.getQualifiedName().toString().equals( Constants.PERSIST_ID_CLASSNAME ) )
110      .findAny()
111      .ifPresent( a -> verifyPersistIdElements( env, env.getElementsAnnotatedWith( a ) ) );
112
113    processTypeElements( annotations,
114                         env,
115                         Constants.PERSIST_TYPE_CLASSNAME,
116                         _deferredTypes,
117                         _analyzePersistTypeStopWatch.getName(),
118                         this::process,
119                         _analyzePersistTypeStopWatch );
120
121    errorIfProcessingOverAndInvalidTypesDetected( env );
122    clearRootTypeNamesIfProcessingOver( env );
123    return true;
124  }
125
126  private void process( @Nonnull final TypeElement element )
127    throws Exception
128  {
129    if ( !AnnotationsUtil.hasAnnotationOfType( element, Constants.AREZ_COMPONENT_CLASSNAME ) )
130    {
131      throw new ProcessorException( MemberChecks.must( Constants.PERSIST_TYPE_CLASSNAME,
132                                                       "be present on a type annotated with the " +
133                                                       MemberChecks.toSimpleName( Constants.AREZ_COMPONENT_CLASSNAME ) +
134                                                       " annotation" ),
135                                    element );
136    }
137
138    final AnnotationMirror annotation =
139      AnnotationsUtil.getAnnotationByType( element, Constants.PERSIST_TYPE_CLASSNAME );
140    final String name = extractPersistTypeName( element, annotation );
141    final boolean persistOnDispose = AnnotationsUtil.getAnnotationValueValue( annotation, "persistOnDispose" );
142    final String defaultStore = extractDefaultStore( element, annotation );
143    final List<ExecutableElement> methods =
144      ElementsUtil.getMethods( element, processingEnv.getElementUtils(), processingEnv.getTypeUtils() );
145    ExecutableElement idMethod = null;
146    final Map<String, PropertyDescriptor> properties = new HashMap<>();
147    for ( final ExecutableElement method : methods )
148    {
149      final AnnotationMirror persistAnnotation =
150        AnnotationsUtil.findAnnotationByType( method, Constants.PERSIST_CLASSNAME );
151      final AnnotationMirror persistIdAnnotation =
152        AnnotationsUtil.findAnnotationByType( method, Constants.PERSIST_ID_CLASSNAME );
153      if ( null != persistAnnotation && null != persistIdAnnotation )
154      {
155        throw new ProcessorException( MemberChecks.mustNot( Constants.PERSIST_ID_CLASSNAME,
156                                                            "also be annotated with the " +
157                                                            MemberChecks.toSimpleName( Constants.PERSIST_CLASSNAME ) +
158                                                            " annotation" ),
159                                      element );
160      }
161      else if ( null != persistAnnotation )
162      {
163        processPersistAnnotation( element, method, persistAnnotation, methods, defaultStore, properties );
164      }
165      else if ( null != persistIdAnnotation )
166      {
167        // These validations have already been performed by an earlier step in this annotation processor
168        // if and only if the type declaring the @PersistId is also compiled in this compilation round.
169        // If the type that encloses @PersistId annotated method is part of a library or compiled earlier
170        // we re-run the checks just in case to avoid scenarios where weird behaviour slips through in code
171        // generation step.
172        validatePersisIdMethod( method );
173        MemberChecks.mustNotBePackageAccessInDifferentPackage( element,
174                                                               Constants.PERSIST_CLASSNAME,
175                                                               Constants.PERSIST_ID_CLASSNAME,
176                                                               method );
177        if ( null != idMethod )
178        {
179          throw new ProcessorException( MemberChecks.mustNot( Constants.PERSIST_ID_CLASSNAME,
180                                                              "be present multiple times within a " +
181                                                              MemberChecks.toSimpleName( Constants.PERSIST_TYPE_CLASSNAME ) +
182                                                              " annotated type but another method annotated with " +
183                                                              MemberChecks.toSimpleName( Constants.PERSIST_ID_CLASSNAME ) +
184                                                              " exists and is named " +
185                                                              idMethod.getSimpleName() ),
186                                        element );
187        }
188        idMethod = method;
189      }
190    }
191
192    if ( properties.isEmpty() )
193    {
194      throw new ProcessorException( MemberChecks.must( Constants.PERSIST_TYPE_CLASSNAME,
195                                                       "contain one or more " +
196                                                       MemberChecks.toSimpleName( Constants.OBSERVABLE_CLASSNAME ) +
197                                                       " properties annotated with " +
198                                                       MemberChecks.toSimpleName( Constants.PERSIST_CLASSNAME ) ),
199                                    element );
200    }
201
202    emitSidecar( new TypeDescriptor( name,
203                                     persistOnDispose,
204                                     element,
205                                     idMethod,
206                                     new ArrayList<>( properties.values() ) ) );
207  }
208
209  private void processPersistAnnotation( @Nonnull final TypeElement element,
210                                         @Nonnull final ExecutableElement method,
211                                         @Nonnull final AnnotationMirror persistAnnotation,
212                                         @Nonnull final List<ExecutableElement> methods,
213                                         @Nonnull final String defaultStore,
214                                         @Nonnull final Map<String, PropertyDescriptor> properties )
215  {
216    final TypeMirror returnType = method.getReturnType();
217    if ( TypeKind.VOID == returnType.getKind() )
218    {
219      throw new ProcessorException( MemberChecks.must( Constants.PERSIST_CLASSNAME,
220                                                       "be present on the accessor method of the " +
221                                                       MemberChecks.toSimpleName( Constants.OBSERVABLE_CLASSNAME ) +
222                                                       " property" ),
223                                    method );
224    }
225
226    final String persistName = extractPersistName( method, persistAnnotation );
227    final String persistStore = extractStore( element, method, persistAnnotation, defaultStore );
228    final String setterName = extractSetterName( persistAnnotation, persistName );
229
230    final ExecutableElement setter = methods
231      .stream()
232      .filter( m -> TypeKind.VOID == m.getReturnType().getKind() &&
233                    setterName.equals( m.getSimpleName().toString() ) &&
234                    1 == m.getParameters().size() &&
235                    processingEnv.getTypeUtils().isSameType( m.getParameters().get( 0 ).asType(), returnType ) )
236      .findAny()
237      .orElse( null );
238    if ( null == setter )
239    {
240      throw new ProcessorException( MemberChecks.must( Constants.PERSIST_CLASSNAME,
241                                                       "be paired with a setter named " + setterName ),
242                                    method );
243    }
244
245    final PropertyDescriptor existing = properties.get( persistName );
246    if ( null != existing )
247    {
248      throw new ProcessorException( MemberChecks.must( Constants.PERSIST_CLASSNAME,
249                                                       "has the same name '" + persistName +
250                                                       "' as another persistent property declared by the name. " +
251                                                       "The other property is accessed by the method named " +
252                                                       existing.getGetter().getSimpleName() ),
253                                    method );
254    }
255    else
256    {
257      properties.put( persistName, new PropertyDescriptor( persistName, persistStore, method, setter ) );
258    }
259  }
260
261  @Nonnull
262  private String extractSetterName( @Nonnull final AnnotationMirror annotation, @Nonnull final String persistName )
263  {
264    final String declaredValue = AnnotationsUtil.getAnnotationValueValue( annotation, "setterName" );
265    return "<default>".equals( declaredValue ) ? "set" + firstCharacterToUpperCase( persistName ) : declaredValue;
266  }
267
268  @Nonnull
269  private String extractPersistTypeName( @Nonnull final TypeElement element,
270                                         @Nonnull final AnnotationMirror annotation )
271  {
272    final String declaredValue = AnnotationsUtil.getAnnotationValueValue( annotation, "name" );
273    if ( DEFAULT_SENTINEL.equals( declaredValue ) )
274    {
275      return element.getSimpleName().toString();
276    }
277    else if ( SourceVersion.isIdentifier( declaredValue ) )
278    {
279      return declaredValue;
280    }
281    else
282    {
283      throw new ProcessorException( MemberChecks.mustNot( Constants.PERSIST_TYPE_CLASSNAME,
284                                                          "specify a name parameter that is not a valid java identifier" ),
285                                    element,
286                                    annotation );
287    }
288  }
289
290  @Nonnull
291  private String extractDefaultStore( @Nonnull final TypeElement element, @Nonnull final AnnotationMirror annotation )
292  {
293    final String defaultStore = AnnotationsUtil.getAnnotationValueValue( annotation, "defaultStore" );
294    if ( !SourceVersion.isIdentifier( defaultStore ) )
295    {
296      throw new ProcessorException( MemberChecks.mustNot( Constants.PERSIST_TYPE_CLASSNAME,
297                                                          "specify a defaultStore parameter that is not a valid java identifier" ),
298                                    element,
299                                    annotation );
300    }
301    return defaultStore;
302  }
303
304  @Nonnull
305  private String extractPersistName( @Nonnull final ExecutableElement method,
306                                     @Nonnull final AnnotationMirror annotation )
307  {
308    final String declaredValue = AnnotationsUtil.getAnnotationValueValue( annotation, "name" );
309    final String value = getPropertyAccessorName( method, declaredValue );
310    if ( SourceVersion.isIdentifier( value ) )
311    {
312      return value;
313    }
314    else
315    {
316      throw new ProcessorException( MemberChecks.mustNot( Constants.PERSIST_CLASSNAME,
317                                                          "specify a name parameter that is not a valid java identifier" ),
318                                    method,
319                                    annotation );
320    }
321  }
322
323  @Nonnull
324  private String extractStore( @Nonnull final TypeElement typeElement,
325                               @Nonnull final ExecutableElement element,
326                               @Nonnull final AnnotationMirror annotation,
327                               @Nonnull final String defaultStore )
328  {
329    final String store = AnnotationsUtil.getAnnotationValueValue( annotation, "store" );
330    if ( DEFAULT_SENTINEL.equals( store ) )
331    {
332      return defaultStore;
333    }
334    else if ( SourceVersion.isIdentifier( store ) )
335    {
336      if ( defaultStore.equals( store ) &&
337           typeElement == element.getEnclosingElement() &&
338           ElementsUtil.isWarningNotSuppressed( element, Constants.WARNING_UNNECESSARY_STORE ) )
339      {
340        final String message =
341          MemberChecks.shouldNot( Constants.PERSIST_CLASSNAME,
342                                  "specify the store parameter when it is the same as the defaultStore " +
343                                  "parameter in the specified by the " +
344                                  MemberChecks.toSimpleName( Constants.PERSIST_TYPE_CLASSNAME ) +
345                                  " annotation on the enclosing type. " +
346                                  MemberChecks.suppressedBy( Constants.WARNING_UNNECESSARY_STORE ) );
347        warning( message, element );
348      }
349      return store;
350    }
351    else
352
353    {
354      throw new ProcessorException( MemberChecks.mustNot( Constants.PERSIST_CLASSNAME,
355                                                          "specify a store parameter that is not a valid java identifier" ),
356                                    element,
357                                    annotation );
358    }
359  }
360
361  private void verifyPersistElements( @Nonnull final RoundEnvironment env,
362                                      @Nonnull final Set<? extends Element> elements )
363  {
364    for ( final Element element : elements )
365    {
366      assert ElementKind.METHOD == element.getKind();
367      if ( !AnnotationsUtil.hasAnnotationOfType( element, Constants.OBSERVABLE_CLASSNAME ) )
368      {
369        reportError( env,
370                     MemberChecks.must( Constants.PERSIST_CLASSNAME,
371                                        "be also be annotated with the " + Constants.OBSERVABLE_CLASSNAME +
372                                        " annotation" ),
373                     element );
374      }
375    }
376  }
377
378  private void verifyPersistIdElements( @Nonnull final RoundEnvironment env,
379                                        @Nonnull final Set<? extends Element> elements )
380  {
381    for ( final Element element : elements )
382    {
383      performAction( env,
384                     _analyzePersistIdStopWatch.getName(), e -> verifyPersistIdElement( env, e ), element,
385                     _analyzePersistIdStopWatch );
386    }
387  }
388
389  private void verifyPersistIdElement( @Nonnull final RoundEnvironment env, @Nonnull final Element element )
390  {
391    assert ElementKind.METHOD == element.getKind();
392    final Element type = element.getEnclosingElement();
393    if ( !AnnotationsUtil.hasAnnotationOfType( type, Constants.AREZ_COMPONENT_CLASSNAME ) &&
394         !AnnotationsUtil.hasAnnotationOfType( type, Constants.AREZ_COMPONENT_LIKE_CLASSNAME ) )
395    {
396      reportError( env,
397                   MemberChecks.must( Constants.PERSIST_ID_CLASSNAME,
398                                      "be enclosed within a type annotated by either the " +
399                                      Constants.AREZ_COMPONENT_CLASSNAME + " annotation or the " +
400                                      Constants.AREZ_COMPONENT_LIKE_CLASSNAME + " annotation" ),
401                   element );
402    }
403
404    validatePersisIdMethod( (ExecutableElement) element );
405  }
406
407  private void validatePersisIdMethod( @Nonnull final ExecutableElement method )
408  {
409    MemberChecks.mustNotBeAbstract( Constants.PERSIST_ID_CLASSNAME, method );
410    MemberChecks.mustNotThrowAnyExceptions( Constants.PERSIST_ID_CLASSNAME, method );
411    MemberChecks.mustNotHaveAnyTypeParameters( Constants.PERSIST_ID_CLASSNAME, method );
412    MemberChecks.mustNotHaveAnyParameters( Constants.PERSIST_ID_CLASSNAME, method );
413    MemberChecks.mustReturnAValue( Constants.PERSIST_ID_CLASSNAME, method );
414    MemberChecks.mustNotBeStatic( Constants.PERSIST_ID_CLASSNAME, method );
415    MemberChecks.mustNotBePrivate( Constants.PERSIST_ID_CLASSNAME, method );
416    MemberChecks.mustNotBeProtected( Constants.PERSIST_ID_CLASSNAME, method );
417  }
418
419  private void emitSidecar( @Nonnull final TypeDescriptor type )
420    throws IOException
421  {
422    final String packageName = GeneratorUtil.getQualifiedPackageName( type.getElement() );
423    emitTypeSpec( packageName, SidecarGenerator.buildType( processingEnv, type ) );
424  }
425
426  @SuppressWarnings( "SameParameterValue" )
427  @Nonnull
428  private String getPropertyAccessorName( @Nonnull final ExecutableElement method, @Nonnull final String specifiedName )
429    throws ProcessorException
430  {
431    String name = deriveName( method, GETTER_PATTERN, specifiedName );
432    if ( null != name )
433    {
434      return name;
435    }
436    if ( method.getReturnType().getKind() == TypeKind.BOOLEAN )
437    {
438      name = deriveName( method, ISSER_PATTERN, specifiedName );
439      if ( null != name )
440      {
441        return name;
442      }
443    }
444    return method.getSimpleName().toString();
445  }
446
447  @Nullable
448  private String deriveName( @Nonnull final ExecutableElement method,
449                             @Nonnull final Pattern pattern,
450                             @Nonnull final String name )
451    throws ProcessorException
452  {
453    if ( DEFAULT_SENTINEL.equals( name ) )
454    {
455      final String methodName = method.getSimpleName().toString();
456      final Matcher matcher = pattern.matcher( methodName );
457      if ( matcher.find() )
458      {
459        final String candidate = matcher.group( 1 );
460        return firstCharacterToLowerCase( candidate );
461      }
462      else
463      {
464        return null;
465      }
466    }
467    else
468    {
469      return name;
470    }
471  }
472
473  @Nonnull
474  private String firstCharacterToLowerCase( @Nonnull final String name )
475  {
476    return Character.toLowerCase( name.charAt( 0 ) ) + name.substring( 1 );
477  }
478
479  @Nonnull
480  private String firstCharacterToUpperCase( @Nonnull final String name )
481  {
482    return Character.toUpperCase( name.charAt( 0 ) ) + name.substring( 1 );
483  }
484}