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