// FlowAnalyzer — feeds AbstractAnalyzer-shaped issues into the // flow editor's CodeController so the bottom diagnostic strip // and the hover tooltip can light up broken lines. // // Issue kinds reported today: // // - YAML parse errors. The exception's source span pins the // line; the message goes straight through. // - `use:` lines pointing at a capability the operator hasn't // installed. Comparison is on the bare `provider/name` (no // version) against the host-supplied capability list, so // `text.echo@^1` matches an installed `text.echo`. // - inputs/outputs values that aren't known type tokens. Catches // `pdf: byes` before it fails at run time. // // On top of issues, the analyzer also publishes a parallel // `Map>` so the editor's tooltip and // strip can render one-click remediations next to the message. import 'package:flutter_code_editor/flutter_code_editor.dart'; import 'package:yaml/yaml.dart'; import 'l10n.dart'; import 'quick_fix.dart'; import 'wire_colors.dart'; /// Reserved step-id sentinels the analyzer uses for issues that /// live in the YAML's pseudo-nodes (`inputs:` and `outputs:` /// blocks), so the graph canvas can highlight those nodes the /// same way it highlights a real step. const String kInputsNodeId = '__inputs__'; const String kOutputsNodeId = '__outputs__'; class FlowAnalyzer extends AbstractAnalyzer { /// Returns the names of capabilities the operator has /// installed. A closure so the analyzer always sees the /// current list without needing a re-create on every Studio /// rebuild. final List Function() availableCapabilities; /// Returns the names of capabilities the store can actually /// install, or null when the store state is UNKNOWN (snapshot /// not loaded / store unreachable). Drives whether an /// unknown-cap issue carries an Install button (in store), the /// "not in store" recovery message (known, absent) or the /// neutral store-unknown wording (null) — the analyzer never /// claims "no store provides it" without a loaded snapshot. final List? Function()? storeCapabilities; /// Quick fixes attached to the most-recent analyze() pass. /// Keyed by the same `Issue` instances that landed in /// `analysisResult.issues`; consumers look up fixes per issue /// rather than per line so a line with multiple issues stays /// disambiguated. final Map> _fixesByIssue = {}; /// Worst-severity per step / pseudo-node from the most-recent /// pass. The graph canvas reads this to decide which nodes /// pulse red (error) / amber (warning). Pseudo-nodes for /// `inputs:` and `outputs:` use [kInputsNodeId] / /// [kOutputsNodeId] sentinels. final Map _stepSeverity = {}; /// Localised string builders the analyzer pipes its messages /// through. Default is English so the analyzer behaves the /// same in tests + host-less callers. final AnalyzerStrings strings; FlowAnalyzer({ required this.availableCapabilities, this.storeCapabilities, this.strings = AnalyzerStrings.english, }); /// Read-only view of the fixes computed during the last /// analyze(). Returns an empty list when no fix is known. List fixesFor(Issue issue) => List.unmodifiable(_fixesByIssue[issue] ?? const []); /// Worst-severity (`error` beats `warning` beats `info`) /// recorded per step / pseudo-node during the last analyze(). /// Caller treats absence as "no issue". Map get stepSeverity => Map.unmodifiable(_stepSeverity); void _bumpSeverity(String stepId, IssueType type) { final current = _stepSeverity[stepId]; if (current == null || _severityRank(type) > _severityRank(current)) { _stepSeverity[stepId] = type; } } static int _severityRank(IssueType t) => switch (t) { IssueType.error => 2, IssueType.warning => 1, IssueType.info => 0, }; @override Future analyze(Code code) async { _fixesByIssue.clear(); _stepSeverity.clear(); final issues = []; final text = code.text; if (text.trim().isEmpty) { return const AnalysisResult(issues: []); } final caps = availableCapabilities(); final knownBareCaps = caps.map(_bareCap).toSet(); // Use both bare names AND fully-qualified `@` // as Did-you-mean candidates so the suggestion can preserve // the version constraint when the user already typed one. final installedFull = caps.toSet(); final storeCaps = storeCapabilities?.call(); final storeKnown = storeCaps != null; final storeBare = (storeCaps ?? const []).map(_bareCap).toSet(); YamlNode? doc; try { doc = loadYamlNode(text); } on YamlException catch (e) { issues.add( Issue( line: e.span?.start.line ?? 0, message: strings.yamlError(e.message), type: IssueType.error, ), ); return AnalysisResult(issues: issues); } catch (e) { issues.add( Issue( line: 0, message: strings.yamlError(e.toString()), type: IssueType.error, ), ); return AnalysisResult(issues: issues); } if (doc is YamlMap) { final steps = doc['steps']; if (steps is YamlList) { for (final step in steps.nodes) { if (step is! YamlMap) continue; final useNode = step.nodes['use']; if (useNode == null) continue; final useValue = useNode.value; if (useValue is! String) continue; if (knownBareCaps.isEmpty) continue; if (!knownBareCaps.contains(_bareCap(useValue))) { final issueLine = useNode.span.start.line; final stepId = _stepIdFor(step); final inStore = storeBare.contains(_bareCap(useValue)); // Did-you-mean candidate: scan installed + store bare // caps for the closest spelling within edit-distance 2. // Wins over the install button when present — typo // fixes are cheaper than network round-trips. // Distance-0 (same word) is never a suggestion — // that case means the bare cap IS in the store // (just not installed yet), so the Install button // is the right call and the Did-you-mean would // confusingly say "use the thing you typed". final didYouMean = _closestCapability( _bareCap(useValue), {...knownBareCaps, ...storeBare} .where((c) => c != _bareCap(useValue)) .toSet(), ); // Tailor the message: "unknown" reads identical in // both cases, but the recovery line nudges toward // the right action. Localised via [strings]. final message = inStore ? strings.unknownCapInStore(useValue) : didYouMean != null ? strings.unknownCapTypo(useValue, didYouMean) : storeKnown ? strings.unknownCapNotInStore(useValue) : strings.unknownCapStoreUnknown(useValue); final issue = Issue( line: issueLine, message: message, type: IssueType.error, ); issues.add(issue); if (stepId != null) { _bumpSeverity(stepId, IssueType.error); } final fixes = []; if (didYouMean != null) { // Try to preserve the version constraint the user // already typed. final versionTail = _versionTail(useValue); final fullReplacement = installedFull.firstWhere( (c) => _bareCap(c) == didYouMean, orElse: () => '$didYouMean$versionTail', ); fixes.add( ReplaceLineValueFix( line: issueLine, replacement: fullReplacement, label: strings.fixUseInstead(fullReplacement), ), ); } if (inStore) { fixes.add( InstallCapabilityFix( capability: useValue, label: strings.fixInstallCap(useValue), ), ); } else if (didYouMean == null) { fixes.add( AddModuleSourceFix( capability: useValue, label: strings.fixAddSource(useValue), ), ); } if (fixes.isNotEmpty) { _fixesByIssue[issue] = fixes; } } } } _checkFieldTypes( doc['inputs'], strings.inputKind(), issues, nodeId: kInputsNodeId, ); _checkFieldTypes( doc['outputs'], strings.outputKind(), issues, nodeId: kOutputsNodeId, ); } return AnalysisResult(issues: issues); } /// Resolve the step's `id` field as a string, or null when /// the step has none (the analyzer doesn't generate /// synthetic ids — a step without an id is itself a /// validation issue handled elsewhere). String? _stepIdFor(YamlMap step) { final id = step.nodes['id']?.value; return id is String ? id : null; } /// Strip the leading `bareCap` so we keep just the /// `@` part. Caller stitches it back onto the /// suggested replacement. String _versionTail(String full) { final at = full.indexOf('@'); return at < 0 ? '' : full.substring(at); } void _checkFieldTypes( YamlNode? block, String kind, List out, { required String nodeId, }) { if (block is! YamlMap) return; for (final entry in block.nodes.entries) { final value = entry.value.value; if (value is! String) continue; // Skip reference expressions like `$inputs.name` or // `$step.field` — only flow `outputs:` use these and they // aren't type declarations. if (value.startsWith(r'$')) continue; // Skip plainly empty values — the operator is typing, not // declaring a typo. if (value.trim().isEmpty) continue; if (!kKnownTypes.contains(value)) { final issue = Issue( line: entry.value.span.start.line, message: strings.unknownType( kind, value, kKnownTypes.join(", "), ), type: IssueType.warning, ); out.add(issue); _bumpSeverity(nodeId, IssueType.warning); final closest = _closestKnownType(value); if (closest != null) { _fixesByIssue[issue] = [ ReplaceLineValueFix( line: entry.value.span.start.line, replacement: closest, label: strings.fixChangeTo(closest), ), ]; } } } } } /// Find the closest capability in [candidates] within /// Levenshtein distance 2. Returns null when nothing is close /// enough — matches the same threshold the type-token nudge /// uses. String? _closestCapability(String input, Set candidates) { String? best; int bestDistance = 3; for (final c in candidates) { final d = _levenshtein(input, c); if (d < bestDistance) { bestDistance = d; best = c; } } return best; } /// Find the closest type in [kKnownTypes] within Levenshtein /// distance 2. Returns `null` when nothing is close enough — /// "totally made up word" should leave the operator picking /// from the full list rather than being nudged toward a poor /// match. String? _closestKnownType(String input) { String? best; int bestDistance = 3; // exclusive upper bound for (final candidate in kKnownTypes) { final d = _levenshtein(input, candidate); if (d < bestDistance) { bestDistance = d; best = candidate; } } return best; } /// Classic dynamic-programming Levenshtein distance — small /// enough to inline since the strings we hit are 4-8 chars. int _levenshtein(String a, String b) { if (a == b) return 0; if (a.isEmpty) return b.length; if (b.isEmpty) return a.length; final n = a.length; final m = b.length; var prev = List.generate(m + 1, (i) => i); var curr = List.filled(m + 1, 0); for (var i = 1; i <= n; i++) { curr[0] = i; for (var j = 1; j <= m; j++) { final cost = a.codeUnitAt(i - 1) == b.codeUnitAt(j - 1) ? 0 : 1; curr[j] = [ curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost, ].reduce((x, y) => x < y ? x : y); } final swap = prev; prev = curr; curr = swap; } return prev[m]; } /// Strip the `@` tail from a capability spec so /// `text.echo@^1` and `text.echo` compare equal. String _bareCap(String full) { final at = full.indexOf('@'); return at < 0 ? full : full.substring(0, at); }