A failed/unloaded store snapshot used to be indistinguishable from a known-empty store, so every missing capability was labelled 'not in store' the moment the hub or store endpoint was unreachable — a wrong claim. storeCapabilities is now nullable (null = unknown): missing caps then get the plain missing chip with an honest tooltip, no install offer and no not-in-store claim; the analyzer message says the store cannot be checked right now (EN+DE). Split and badge covered by new unit + widget tests. Signed-off-by: flemming-it <sf@flemming.it>
377 lines
13 KiB
Dart
377 lines
13 KiB
Dart
// 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<Issue, List<QuickFix>>` 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<String> 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<String>? 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<Issue, List<QuickFix>> _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<String, IssueType> _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<QuickFix> 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<String, IssueType> 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<AnalysisResult> analyze(Code code) async {
|
|
_fixesByIssue.clear();
|
|
_stepSeverity.clear();
|
|
final issues = <Issue>[];
|
|
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 `<bare>@<version>`
|
|
// 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 <String>[]).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 = <QuickFix>[];
|
|
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
|
|
/// `@<version>` 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<Issue> 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<String> 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<int>.generate(m + 1, (i) => i);
|
|
var curr = List<int>.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 `@<version>` 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);
|
|
}
|