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

163 lines
7.3 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). Monaco merges this with the schema's own hovers. (Expression-string
* hovers and completion live in services/spec-expression-hints.)
* - **Inlay hints** — a faint `·<data-type>` beside each `field` (the column's
* raw type: number/string/date/boolean), annotation without touching the text.
* Deliberately the *data* type, not the encoding `type` — they share the line,
* so a `: quantitative` here would read as annotating the adjacent `"type"`,
* which the hint never describes.
*
* 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 { valueKeyAtOffset } from '@core/spec-cursor';
import { useSnippetStore } from '../stores/SnippetStore';
import {
availableFieldsAt,
dataInfoAt,
fieldTypeAt,
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']);
/** 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_` }];
}
// The column's raw data type (number/string/date/boolean) — same vocabulary as
// the inlay hint, not the Vega-Lite encoding `type` the user declares.
const lines = [`**${hint.name}** · \`${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 text = model.getValue();
const offset = model.getOffsetAt(position);
const key = valueKeyAtOffset(text, offset);
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
const fields = availableFieldsAt(text, offset);
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' : f.type,
insertText: f.name,
range,
})),
};
},
});
// All three providers annotate the draft only: they derive their field set from
// the draft buffer (dataInfoAt), 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 a field name: its type + stats, resolved at this view's data binding.
// (Expression-string hovers live in services/spec-expression-hints.)
const word = model.getWordAtPosition(position);
if (word) {
const info = dataInfoAt(text, offset);
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 text = model.getValue();
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;
// Resolve the type at this field's own data binding — views in a
// composition can bind different datasets. The offset points inside the
// field's value so the path resolves to its enclosing view.
const valueCol = match.index + match[0].length - match[1].length;
const offset = model.getOffsetAt({ lineNumber: line, column: valueCol });
const type = fieldTypeAt(text, offset, match[1]);
if (!type) continue; // unknown or derived (no type to annotate)
hints.push({
position: { lineNumber: line, column: match.index + match[0].length + 1 },
// The column's raw data type (number/string/date/boolean), not the
// Vega-Lite encoding `type`: a `: quantitative` here would read as an
// annotation of the adjacent `"type"`, which the hint never describes.
// The leading `·` marks it as a data fact about the field, not JSON syntax.
label: `·${type}`,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
return { hints, dispose() {} };
},
});
}