deriveGoRouter function

GoRouterDerivation deriveGoRouter(
  1. List<ScannedFile> files,
  2. void warn(
    1. String
    ), {
  3. Map<String, String> manualScreenClasses = const {},
  4. Map<String, String> extraClassToScreen = const {},
  5. Map<String, String> extraPathToScreen = const {},
})

Leitet Screens, Flows (ShellRoute), Redirect-Decisions und Navigations-Kanten aus go_router-Konfigurationen ab.

manualScreenClasses (Weg A/B) und extraClassToScreen (z. B. auto_route) werden für die from-Zuordnung von context.go-Aufrufen mitverwendet, extraPathToScreen für die Pfad-Zuordnung.

Implementation

GoRouterDerivation deriveGoRouter(
  List<ScannedFile> files,
  void Function(String) warn, {
  Map<String, String> manualScreenClasses = const {},
  Map<String, String> extraClassToScreen = const {},
  Map<String, String> extraPathToScreen = const {},
}) {
  final result = GoRouterDerivation();
  result.pathToScreen.addAll(extraPathToScreen);
  final pendingRedirects = <_PendingRedirect>[];
  var shellIndex = 0;

  // Pass 1 — Routenbäume: Screens, Flows, builder-Zuordnung, Pfad-/Namens-
  // Tabellen. Dateien sind nach Pfad sortiert, im Dokument nach Offset.
  for (final file in files) {
    final collector = _RouteCollector();
    file.unit.accept(collector);
    final visited = <AstNode>{};

    // Flow-Kontext des innersten ShellRoute; start = erster Kind-Screen.
    void processRoute(_RouteCall call,
        {String parentPath = '', String? flowId, List<String?>? flowStart}) {
      visited.add(call.node);

      String? childFlowId = flowId;
      List<String?>? childFlowStart = flowStart;
      var childParentPath = parentPath;

      if (call.name == 'ShellRoute') {
        childFlowId = 'shell-$shellIndex';
        shellIndex++;
        childFlowStart = [null];
      } else {
        // GoRoute
        final pathExpr = call.namedArg('path');
        final nameExpr = call.namedArg('name');
        final path = pathExpr is StringLiteral ? pathExpr.stringValue : null;
        final name = nameExpr is StringLiteral ? nameExpr.stringValue : null;

        if (path == null && name == null) {
          warn('Hinweis: ${file.relPath}:${file.lineOf(call.node.offset)}: '
              'GoRoute ohne literalen path/name — übersprungen.');
        } else {
          // Id aus dem gejointen Vollpfad bilden — der relative Pfad allein
          // würde verschachtelte Routen mit gleichem Segment (z. B. 'detail'
          // unter '/user' und '/admin') stillschweigend kollabieren lassen.
          final fullPath = path == null ? null : _joinPaths(parentPath, path);
          final id = name ?? slugFromPath(fullPath!);
          result.nodes.add(GraphNode(
            id: id,
            type: 'screen',
            title: humanize(id),
            flow: flowId,
            source: SourceKind.derived,
            sourceRef: file.refAt(call.node.offset, symbol: name ?? path),
          ));
          if (flowStart != null) flowStart[0] ??= id;
          if (name != null) result.nameToScreen.putIfAbsent(name, () => id);
          if (fullPath != null) {
            childParentPath = fullPath;
            result.pathToScreen.putIfAbsent(fullPath, () => id);
          }

          final builder =
              call.namedArg('builder') ?? call.namedArg('pageBuilder');
          if (builder != null) {
            final widgetClass = _widgetClassOf(builder);
            if (widgetClass != null) {
              result.builderClassToScreen.putIfAbsent(widgetClass, () => id);
            }
          }

          final redirect = call.namedArg('redirect');
          if (redirect != null) {
            pendingRedirects
                .add(_PendingRedirect(id, humanize(id), redirect, file));
          }
        }
      }

      final routesExpr = call.namedArg('routes');
      if (routesExpr is ListLiteral) {
        for (final element in routesExpr.elements) {
          final child = _RouteCall.of(element);
          if (child != null &&
              (child.name == 'GoRoute' || child.name == 'ShellRoute')) {
            processRoute(child,
                parentPath: childParentPath,
                flowId: childFlowId,
                flowStart: childFlowStart);
          }
        }
      }

      if (call.name == 'ShellRoute') {
        final start = childFlowStart![0];
        if (start == null) {
          warn('Hinweis: ${file.relPath}:${file.lineOf(call.node.offset)}: '
              'ShellRoute ohne Kind-Screens — Flow "$childFlowId" entfällt.');
        } else {
          result.flows.add(GraphFlow(
            id: childFlowId!,
            title: humanize(childFlowId),
            start: start,
            source: SourceKind.derived,
            sourceRef: file.refAt(call.node.offset),
          ));
        }
      }
    }

    for (final call in collector.calls) {
      if (!visited.contains(call.node)) processRoute(call);
    }
  }

  // Pass 2a — Redirect-Decisions (jetzt sind alle Routen-Pfade bekannt).
  for (final pending in pendingRedirects) {
    final decisionId = '${pending.screenId}_redirect';
    final ref = pending.file.refAt(pending.body.offset);
    result.nodes.add(GraphNode(
      id: decisionId,
      type: 'decision',
      title: 'Weiterleitung: ${pending.screenTitle}',
      source: SourceKind.derived,
      sourceRef: ref,
    ));
    result.edges.add(GraphEdge(
      from: decisionId,
      to: pending.screenId,
      trigger: 'auto',
      source: SourceKind.derived,
      sourceRef: ref,
    ));

    final literals = _StringLiteralCollector();
    pending.body.accept(literals);
    final seenTargets = <String>{};
    for (final value in literals.values) {
      final target = result.pathToScreen[value];
      if (target == null || target == pending.screenId) continue;
      if (!seenTargets.add(target)) continue;
      result.edges.add(GraphEdge(
        from: decisionId,
        to: target,
        trigger: 'auto',
        condition: 'redirect',
        source: SourceKind.derived,
        sourceRef: ref,
      ));
    }
  }

  // Pass 2b — Navigations-Aufrufe (context.go/push/goNamed/pushNamed/replace).
  String? classToScreen(String className) =>
      manualScreenClasses[className] ??
      extraClassToScreen[className] ??
      result.builderClassToScreen[className];

  for (final file in files) {
    final collector = _NavCallCollector();
    file.unit.accept(collector);
    for (final call in collector.calls) {
      // Erstes positionales Argument muss ein String-Literal sein.
      Expression? first;
      for (final arg in call.argumentList.arguments) {
        if (arg is Expression) {
          first = arg;
          break;
        }
      }
      if (first is! StringLiteral) continue;
      final literal = first.stringValue;
      if (literal == null) continue;

      final where = '${file.relPath}:${file.lineOf(call.offset)}';
      final methodName = call.methodName.name;
      final String? to;
      if (_navByName.contains(methodName)) {
        to = result.nameToScreen[literal];
      } else {
        // Query-Anteil ignorieren ('/login?next=…').
        to = result.pathToScreen[literal.split('?').first];
      }
      if (to == null) {
        warn('Hinweis: $where: $methodName("$literal") entspricht keiner '
            'bekannten Route — Kante verworfen.');
        continue;
      }

      final enclosing = _enclosingClassOf(call);
      final from = enclosing == null ? null : classToScreen(enclosing.namePart.typeName.lexeme);
      if (from == null) {
        warn('Hinweis: $where: $methodName("$literal") keinem Screen '
            'zuordenbar (umschließende Klasse unbekannt) — Kante verworfen.');
        continue;
      }

      result.edges.add(GraphEdge(
        from: from,
        to: to,
        trigger: 'tap',
        source: SourceKind.derived,
        sourceRef: file.refAt(call.offset, symbol: enclosing!.namePart.typeName.lexeme),
      ));
    }
  }

  return result;
}