Something went wrong. Try again.
Identities for entities did.bot
agent llm did
Something went wrong. Try again.
13 kB · 334 lines
TypeScript
at commit 18ba4fe0
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335// The policy document's editor: CodeMirror, holding JSON read under the// engine's own grammar (Checks.grammars), or Cedar text read under the Cedar// schema. It colours what src/ui/snippet.ts colours, with the same classes;// suggests what the grammar allows; and shows the checks' problems in place.//// It lives in a shadow root. CodeMirror injects its own styles through// style-mod, which gives a shadow root a constructable stylesheet and a// document a <style> element, and the deployed style-src grants no// 'unsafe-inline' for the latter. The page's own stylesheet is linked into the// shadow root too, so the token classes read as they do everywhere else.
import { autocompletion, completionKeymap, snippetCompletion, type Completion, type CompletionContext, type CompletionResult,} from "@codemirror/autocomplete";import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";import { json, jsonParseLinter } from "@codemirror/lang-json";import { bracketMatching, ensureSyntaxTree, syntaxTree } from "@codemirror/language";import { lintGutter, setDiagnostics, type Diagnostic } from "@codemirror/lint";import { Compartment, EditorState, StateEffect } from "@codemirror/state";import { Decoration, EditorView, keymap, lineNumbers, ViewPlugin, type DecorationSet, type ViewUpdate,} from "@codemirror/view";import type { SyntaxNode, Tree } from "@lezer/common";import type { CedarGrammar, FieldValue, Problem, RegexGrammar } from "../checks.ts";import { h } from "../dom.ts";import { completeCedar } from "./cedar-complete.ts";import { cedarStatements, cedarTokens, statementNamed, type CedarStatement } from "./cedar-tokens.ts";import { marks, memberString, nodeAtPath, properties, roleOf, type Read, type Segment } from "./json-tree.ts";import { kindFields, type PolicySyntax, type Role } from "./policy-syntax.ts";
/** A problem, with its path below the editor's top value. */export interface PlacedProblem extends Problem { at: Segment[];}
/** What the editor holds: JSON whose top value sits in `root`, or Cedar text. */export type EditorMode = | { language: "json"; root: Role } | { language: "cedar"; grammar: CedarGrammar | undefined };
export interface DocumentEditor { element: HTMLElement; text(): string; /** Replaces the text, and what it holds. */ load(text: string, mode: EditorMode): void; /** Shows these problems in the text, and any JSON syntax error. */ diagnose(problems: PlacedProblem[]): void; focus(): void;}
const remode = StateEffect.define<null>();
export function documentEditor(options: { label: string; syntax: PolicySyntax; text: string; mode: EditorMode; onChange: () => void;}): DocumentEditor { const { syntax } = options; let mode = options.mode; const language = new Compartment(); const languageOf = (next: EditorMode) => (next.language === "json" ? json() : []);
const element = h("div", { class: "document-editor" }); const shadow = element.attachShadow({ mode: "open" }); for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) { shadow.append(link.cloneNode(true)); }
const colours = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { this.decorations = decorate(view.state); } update(update: ViewUpdate) { if ( update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state) || update.transactions.some((tr) => tr.effects.some((effect) => effect.is(remode))) ) { this.decorations = decorate(update.state); } } }, { decorations: (plugin) => plugin.decorations }, ); const decorate = (state: EditorState): DecorationSet => Decoration.set( (mode.language === "json" ? marks(treeOf(state), reader(state), syntax, mode.root) : cedarTokens(state.doc.toString()) ) .filter((mark) => mark.to > mark.from) .map((mark) => Decoration.mark({ class: mark.class }).range(mark.from, mark.to)), true, );
const view = new EditorView({ root: shadow, parent: shadow, state: EditorState.create({ doc: options.text, extensions: [ lineNumbers(), lintGutter(), history(), bracketMatching(), language.of(languageOf(mode)), colours, autocompletion({ icons: false, override: [ (context) => mode.language === "json" ? complete(context, syntax, mode.root) : mode.grammar ? completeCedar(context, mode.grammar) : null, ], }), keymap.of([...completionKeymap, ...defaultKeymap, ...historyKeymap]), EditorView.lineWrapping, EditorView.contentAttributes.of({ "aria-label": options.label }), EditorView.updateListener.of((update) => { if (update.docChanged) options.onChange(); }), theme, ], }), });
return { element, text: () => view.state.doc.toString(), load(text, next) { mode = next; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, effects: [language.reconfigure(languageOf(next)), remode.of(null)], }); }, diagnose(problems) { const state = view.state; if (mode.language === "cedar") { const statements = cedarStatements(state.doc.toString()); view.dispatch(setDiagnostics(state, problems.map((problem) => placeCedar(problem, state, statements)))); return; } const syntaxError = jsonParseLinter()(view); const placed = syntaxError.length ? [] : problems.map((problem) => place(problem, state, treeOf(state))); view.dispatch(setDiagnostics(state, [...syntaxError, ...placed])); }, focus: () => view.focus(), };}
/** * Where in the text a problem sits: the node its path names, or the nearest * one on the way there. A problem about the whole document sits on its first * line. */function place(problem: PlacedProblem, state: EditorState, tree: Tree): Diagnostic { const found = problem.at.length ? nodeAtPath(tree, reader(state), problem.at) : null; const from = found?.node.from ?? 0; const to = found?.node.to ?? state.doc.line(1).to; return { from, to: Math.min(Math.max(to, from + 1), state.doc.length), severity: problem.severity, message: `${problem.path}: ${problem.message}`, };}
/** Where a Cedar problem sits: the statement it names, or the first line. */function placeCedar(problem: PlacedProblem, state: EditorState, statements: CedarStatement[]): Diagnostic { const statement = statementNamed(problem.message, statements); const from = statement?.from ?? 0; const to = statement?.to ?? state.doc.line(1).to; return { from, to: Math.min(Math.max(to, from + 1), state.doc.length), severity: problem.severity, message: `${problem.path}: ${problem.message}`, };}
function reader(state: EditorState): Read { return (from, to) => state.doc.sliceString(from, to);}
/** The whole text's syntax tree; a policy document is small. */function treeOf(state: EditorState): Tree { return ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state);}
/** Completions the grammar allows where the cursor is. */function complete(context: CompletionContext, syntax: PolicySyntax, root: Role): CompletionResult | null { if (root.at === "value") return null; const { state, pos } = context; const word = context.matchBefore(/"?[\w$#.-]*/); if (!word || (word.from === word.to && !context.explicit)) return null; const read = reader(state); const tree = treeOf(state); const before = state.sliceDoc(Math.max(0, word.from - 400), word.from); const lead = before.trimEnd().slice(-1); const container = enclosing(tree.resolveInner(word.from, -1)); if (!container) return null; const role = roleOf(container, read, syntax, root); // Replace a whole string when the cursor is inside one already. const token = tree.resolveInner(word.from, 1); const to = (token.name === "String" || token.name === "PropertyName") && token.from === word.from ? token.to : pos;
if (container.name === "Array" && role.at === "statements" && (lead === "[" || lead === ",")) { return { from: word.from, to, options: statementSnippets(role.grammar) }; } if (container.name !== "Object" || role.at === "value" || role.at === "statements" || role.at === "patterns") { return null; } const grammar = role.grammar; if (lead === ":") { const key = /"((?:[^"\\]|\\.)*)"\s*:\s*$/.exec(before)?.[1]; const words = role.at === "statement" && key === grammar.kindField ? grammar.kinds.map((kind) => kind.kind) : role.at === "pattern" && key === grammar.patternContains ? grammar.contains : null; if (!words) return null; return { from: word.from, to, options: words.map((w) => ({ label: JSON.stringify(w), displayLabel: w })) }; } if (lead === "{" || lead === ",") { const present = new Set(properties(container, read).map((property) => property.name)); const kind = memberString(container, grammar.kindField, read); const fields: Completion[] = role.at === "statement" ? [ { label: JSON.stringify(grammar.kindField), displayLabel: grammar.kindField, apply: `"${grammar.kindField}": ` }, ...kindFields(grammar, kind).map((field) => member(field, grammar)), ] : [grammar.patternRegex, grammar.patternContains].map((name) => ({ label: JSON.stringify(name), displayLabel: name, apply: `"${name}": `, })); return { from: word.from, to, options: fields.filter((field) => !present.has(field.displayLabel)) }; } return null;}
/** The snippet body a field of this kind is filled with. */function fieldTemplate(field: { name: string; value: FieldValue }, grammar: RegexGrammar): string { if (field.value === "patterns") return `[{ "${grammar.patternRegex}": "\${${field.name}}" }]`; // A number is written bare: the engine reads maxSize as a whole number, // and refuses it quoted. if (field.value === "number") return `\${${field.name}}`; return `"\${${field.name}}"`;}
/** A member completion for a statement field. */function member(field: { name: string; value: FieldValue }, grammar: RegexGrammar): Completion { const template = `"${field.name}": ${fieldTemplate(field, grammar)}`; return snippetCompletion(template, { label: JSON.stringify(field.name), displayLabel: field.name, detail: field.value, });}
/** One new statement per kind, with every field it reads. */function statementSnippets(grammar: RegexGrammar): Completion[] { return grammar.kinds.map(({ kind, fields }) => { const lines = [ `\t"${grammar.kindField}": ${JSON.stringify(kind)}`, ...fields.map((field) => `\t"${field.name}": ${fieldTemplate(field, grammar)}`), ]; return snippetCompletion(`{\n${lines.join(",\n")}\n}`, { label: kind, detail: "statement", type: "class" }); });}
/** The nearest Object or Array holding `node`. */function enclosing(node: SyntaxNode | null): SyntaxNode | null { for (let at = node; at; at = at.parent) if (at.name === "Object" || at.name === "Array") return at; return null;}
// Colours come from src/style.css's custom properties, which reach into the// shadow root through inheritance.const theme = EditorView.theme( { "&": { color: "var(--ink)", backgroundColor: "var(--inset)", border: "1px solid var(--line)", borderRadius: "4px", fontSize: "0.9rem", }, "&.cm-focused": { outline: "none", borderColor: "var(--neon)" }, ".cm-scroller": { fontFamily: "var(--mono)", lineHeight: "1.5", minHeight: "12rem", maxHeight: "28rem" }, ".cm-content": { caretColor: "var(--neon)", outline: "none", boxShadow: "none" }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--neon)" }, ".cm-gutters": { backgroundColor: "var(--inset)", color: "var(--muted)", borderRight: "1px solid var(--line)" }, ".cm-matchingBracket": { outline: "1px solid var(--neon)", backgroundColor: "transparent" }, ".cm-tooltip": { backgroundColor: "var(--panel)", color: "var(--ink)", border: "1px solid var(--neon)", borderRadius: "4px", }, ".cm-tooltip.cm-tooltip-autocomplete > ul": { fontFamily: "var(--mono)", maxHeight: "16rem" }, ".cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]": { backgroundColor: "var(--neon)", color: "var(--ground)", }, ".cm-completionDetail": { color: "var(--muted)", fontStyle: "normal" }, ".cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail": { color: "inherit" }, ".cm-diagnostic": { fontFamily: "var(--mono)", whiteSpace: "pre-wrap" }, ".cm-diagnostic-error": { borderLeftColor: "var(--error)" }, ".cm-diagnostic-warning": { borderLeftColor: "var(--warn)" }, }, { dark: true },);