Files
astrolabe/src/app/services/spec-dataset-hints.ts
T

167 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dataset-aware editor hints (docs/architecture/08 → editor augmentation) — the
* three things the Vega-Lite JSON schema *can't* know, because they depend on the
* user's data and this spec:
*
* - **Completion** — in a `field` / `groupby` value, the bound dataset's real
* column names (plus the spec's derived fields). The schema only knows `field`
* takes a string; it can't list your columns. Enum values (`type`, `mark`, …)
* are left to the schema — we add only what it lacks, no second source.
* - **Hover** — a column's inferred type + cardinality/range (from the stored
* profile); over a `calculate`/`filter`/`expr` string, a live validity check
* via core/expr-validate. Monaco merges these with the schema's own hovers.
* - **Inlay hints** — a faint `: <type>` beside each `field`, annotation without
* touching the text.
*
* All three read the active draft's bound dataset (services/active-dataset) and
* the cursor's JSON context (core/spec-cursor) at provide-time via `getState()` —
* outside React. They register **once, globally for JSON** (like the schema and
* formatter), not per editor. Suggestion-only: over- or under-listing is
* harmless, which is why there is deliberately no "unknown field" diagnostic
* (that would false-positive on every data-dependent derived column).
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { defaultFieldType } from '@core/chart-builder';
import { validateExpression } from '@core/expr-validate';
import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
import type { ColumnType } from '@core/type-inference';
import { useSnippetStore } from '../stores/SnippetStore';
import { availableFields, dataInfo, type DataInfo, type FieldHint } from './active-dataset';
/** Property values that reference a data field (where column names belong). */
const FIELD_KEYS = new Set(['field', 'groupby']);
/** Property values that hold a Vega expression (where the validity check fires). */
const EXPR_KEYS = new Set(['calculate', 'filter', 'expr']);
/** A short type label for a source field; null type (derived) is shown elsewhere. */
const typeLabel = (type: ColumnType): string => defaultFieldType(type);
/** Markdown hover for a field hint: type + stats for source, a note for derived. */
function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
if (hint.derived || hint.type === null) {
return [{ value: `**${hint.name}** · _derived by a transform_` }];
}
const lines = [`**${hint.name}** · \`${typeLabel(hint.type)}\``];
const stat = info.columnStats.find((s) => s.name === hint.name);
if (stat) {
if (stat.numericExtent)
lines.push(`Range ${stat.numericExtent.min} ${stat.numericExtent.max}`);
lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`);
}
lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the specs inline data_');
return lines.map((value) => ({ value }));
}
let registered = false;
/** Register the dataset-aware completion / hover / inlay providers once. */
export function configureSpecDatasetHints(): void {
if (registered) return;
registered = true;
monaco.languages.registerCompletionItemProvider('json', {
triggerCharacters: ['"'],
provideCompletionItems(model, position) {
// Field suggestions only edit the draft; nothing to offer on the published view.
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
const key = valueKeyAtOffset(model.getValue(), model.getOffsetAt(position));
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
const fields = availableFields();
if (fields.length === 0) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
return {
suggestions: fields.map((f) => ({
label: f.name,
kind: f.derived
? monaco.languages.CompletionItemKind.Variable
: monaco.languages.CompletionItemKind.Field,
detail: f.derived || f.type === null ? 'derived field' : typeLabel(f.type),
insertText: f.name,
range,
})),
};
},
});
// All three providers annotate the draft only: they derive their field set from
// the draft buffer (dataInfo), so gating to the draft view keeps the hints
// consistent with the text they are computed from. The published view is a
// read-only reference, where field hints are marginal.
monaco.languages.registerHoverProvider('json', {
provideHover(model, position) {
if (useSnippetStore.getState().editorView !== 'draft') return null;
const text = model.getValue();
const offset = model.getOffsetAt(position);
// Over an expression value: a live validity check.
const key = valueKeyAtOffset(text, offset);
if (key !== null && EXPR_KEYS.has(key)) {
const expr = stringValueAtOffset(text, offset);
if (expr !== null) {
const result = validateExpression(expr);
return {
contents: [
{
value: result.valid
? '✓ Valid Vega expression'
: `${result.error ?? 'Invalid expression'}`,
},
],
};
}
}
// Over a field name: its type + stats.
const word = model.getWordAtPosition(position);
if (word) {
const info = dataInfo();
const hint = info.fields.find((f) => f.name === word.word);
if (hint) {
return {
range: new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
),
contents: fieldHoverContents(hint, info),
};
}
}
return null;
},
});
monaco.languages.registerInlayHintsProvider('json', {
provideInlayHints(model, range) {
if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} };
const types = new Map(availableFields().map((f) => [f.name, f.type]));
if (types.size === 0) return { hints: [], dispose() {} };
const hints: monaco.languages.InlayHint[] = [];
for (let line = range.startLineNumber; line <= range.endLineNumber; line++) {
// First `field` per line; the compact format keeps each encoding channel
// (and its lone field) on its own line, so one match per line suffices.
const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line));
if (!match) continue;
const type = types.get(match[1]);
if (!type) continue; // unknown or derived (no type to annotate)
hints.push({
position: { lineNumber: line, column: match.index + match[0].length + 1 },
label: `: ${typeLabel(type)}`,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
return { hints, dispose() {} };
},
});
}