extractAnnotations function

ManualExtraction extractAnnotations(
  1. ScannedFile file,
  2. void warn(
    1. String
    ),
  3. List<String> errors, {
  4. AnnotationResolution? resolution,
})

Extrahiert alle Journey-Annotationen einer Datei.

resolution (Weg D) löst nicht-literale konstante Argumente auf; ohne Auflösung bleibt das parse-only-Verhalten byte-identisch erhalten.

Implementation

ManualExtraction extractAnnotations(
  ScannedFile file,
  void Function(String) warn,
  List<String> errors, {
  AnnotationResolution? resolution,
}) {
  final result = ManualExtraction();

  void handleAnnotation(
    Annotation annotation, {
    required String symbol,
    String? enclosingClassName,
  }) {
    final name = _annotationName(annotation);
    final ref = file.refAt(annotation.offset, symbol: symbol);
    final where = '${file.relPath}:${ref.line}';
    final args = _namedArgs(annotation);

    /// Liest ein benanntes String-Argument. Ist das Argument zwar vorhanden,
    /// aber parse-only nicht literal lesbar (z. B. eine Const-Referenz,
    /// DD §B.5), versucht zuerst [resolution] (Weg D) die konstante
    /// Auflösung; scheitert auch die, wird das diagnostiziert statt
    /// stillschweigend verworfen: Warnung für optionale Felder, Fehler bei
    /// [errorIfUnreadable].
    String? str(String key, {bool errorIfUnreadable = false}) {
      final expr = args[key];
      if (expr == null) return null;
      final value =
          _stringValue(expr) ?? resolution?.stringValue(file, annotation, key);
      if (value == null) {
        if (errorIfUnreadable) {
          errors.add('$where: @$name: "$key" nicht literal lesbar '
              '(nur String-Literale werden parse-only unterstützt).');
        } else {
          warn('Warnung: $where: @$name: "$key" nicht literal lesbar '
              '— ignoriert.');
        }
      }
      return value;
    }

    /// Liest das optionale `tags:`-Argument. Nicht (vollständig) literal
    /// lesbare Listen werden zuerst über [resolution] (Weg D) konstant
    /// aufgelöst; scheitert das, greift die parse-only-Semantik: nicht-
    /// literale Liste bzw. nicht-literale Elemente mit Warnung überspringen.
    List<String> tagsValue() {
      final expr = args['tags'];
      if (expr == null) return const [];
      if (expr is! ListLiteral) {
        final resolved = resolution?.stringListValue(file, annotation, 'tags');
        if (resolved != null) return resolved;
        warn('Warnung: $where: @$name: "tags" nicht literal lesbar '
            '— ignoriert.');
        return const [];
      }
      final values = <String>[];
      var allLiteral = true;
      for (final element in expr.elements) {
        final v = element is StringLiteral ? element.stringValue : null;
        if (v == null) {
          allLiteral = false;
        } else {
          values.add(v);
        }
      }
      // Voll literal ⇒ Paritätspfad, identisch zum parse-only-Extraktor.
      if (allLiteral) return values;
      final resolved = resolution?.stringListValue(file, annotation, 'tags');
      if (resolved != null) return resolved;
      // parse-only-Fallback: Warnung je nicht lesbarem Element, Rest behalten.
      for (final element in expr.elements) {
        if (element is! StringLiteral || element.stringValue == null) {
          warn('Warnung: $where: @$name: "tags"-Element nicht literal '
              'lesbar — ignoriert.');
        }
      }
      return values;
    }

    switch (name) {
      case 'JourneyScreen':
      case 'JourneyDecision':
        final id = str('id', errorIfUnreadable: true);
        final title = str('title', errorIfUnreadable: true);
        if (id == null || title == null) {
          if (!args.containsKey('id') || !args.containsKey('title')) {
            errors.add('$where: @$name benötigt literale "id" und "title".');
          }
          return;
        }
        final type = name == 'JourneyScreen' ? 'screen' : 'decision';
        result.nodes.add(GraphNode(
          id: id,
          type: type,
          title: title,
          flow: str('flow'),
          description: str('description'),
          tags: tagsValue(),
          source: SourceKind.annotation,
          sourceRef: ref,
        ));
        if (type == 'screen') {
          result.screenClassNames.putIfAbsent(symbol, () => id);
        }
      case 'JourneyAction':
        final label = str('label', errorIfUnreadable: true);
        final to = str('to', errorIfUnreadable: true);
        if (label == null || to == null) {
          if (!args.containsKey('label') || !args.containsKey('to')) {
            errors
                .add('$where: @JourneyAction benötigt literale "label" und "to".');
          }
          return;
        }
        var trigger = 'tap';
        if (args.containsKey('trigger')) {
          var t = _triggerValue(args['trigger']!);
          if (t == null || !validTriggers.contains(t)) {
            // Weg D: konstante Referenz (z. B. `MyConsts.trigger`) auflösen.
            t = resolution?.triggerValue(file, annotation, 'trigger') ?? t;
          }
          if (t == null || !validTriggers.contains(t)) {
            warn('Warnung: $where: trigger nicht literal lesbar — '
                'verwende "tap".');
          } else {
            trigger = t;
          }
        }
        // Explizit gesetztes, aber nicht lesbares `from` ist ein Fehler —
        // sonst würde die from-Inferenz den gemeinten Wert stillschweigend
        // durch die umschließende Klasse ersetzen (falsche Kante).
        final from = str('from', errorIfUnreadable: true);
        if (from == null && args.containsKey('from')) return;
        if (from == null && enclosingClassName == null) {
          errors.add('$where: @JourneyAction ohne "from" und ohne '
              'umschließende Klasse — "from" nicht bestimmbar.');
          return;
        }
        result.actions.add(ActionCandidate(
          id: str('id'),
          label: label,
          to: to,
          from: from,
          trigger: trigger,
          condition: str('condition'),
          enclosingClassName: enclosingClassName,
          sourceRef: ref,
        ));
      case 'JourneyFlow':
        final id = str('id', errorIfUnreadable: true);
        final title = str('title', errorIfUnreadable: true);
        final start = str('start', errorIfUnreadable: true);
        if (id == null || title == null || start == null) {
          if (!args.containsKey('id') ||
              !args.containsKey('title') ||
              !args.containsKey('start')) {
            errors.add(
                '$where: @JourneyFlow benötigt literale "id", "title" und "start".');
          }
          return;
        }
        result.flows.add(GraphFlow(
          id: id,
          title: title,
          start: start,
          description: str('description'),
          source: SourceKind.annotation,
          sourceRef: ref,
        ));
    }
  }

  for (final directive in file.unit.directives) {
    if (directive is LibraryDirective) {
      for (final annotation in directive.metadata) {
        handleAnnotation(annotation, symbol: file.relPath);
      }
    }
  }

  for (final decl in file.unit.declarations) {
    if (decl is ClassDeclaration) {
      final className = decl.namePart.typeName.lexeme;
      for (final annotation in decl.metadata) {
        handleAnnotation(annotation, symbol: className);
      }
      for (final member in decl.body.members) {
        if (member is MethodDeclaration) {
          for (final annotation in member.metadata) {
            handleAnnotation(
              annotation,
              symbol: member.name.lexeme,
              enclosingClassName: className,
            );
          }
        } else if (member is FieldDeclaration) {
          final fieldName = member.fields.variables.isEmpty
              ? className
              : member.fields.variables.first.name.lexeme;
          for (final annotation in member.metadata) {
            handleAnnotation(
              annotation,
              symbol: fieldName,
              enclosingClassName: className,
            );
          }
        }
      }
    } else if (decl is FunctionDeclaration) {
      for (final annotation in decl.metadata) {
        handleAnnotation(annotation, symbol: decl.name.lexeme);
      }
    } else if (decl is TopLevelVariableDeclaration) {
      final varName = decl.variables.variables.isEmpty
          ? file.relPath
          : decl.variables.variables.first.name.lexeme;
      for (final annotation in decl.metadata) {
        handleAnnotation(annotation, symbol: varName);
      }
    }
  }

  return result;
}