mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
/**
|
||
* 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 spec’s 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() {} };
|
||
},
|
||
});
|
||
}
|