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