Skip to main content
Module

x/ddc_vim/context.ts

Dark deno-powered completion framework for neovim/Vim
Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
import { assertEquals, collect, Denops, ensure, fn, is, op, vars,} from "./deps.ts";import { BaseFilterParams, BaseSourceParams, BaseUiParams, Context, DdcEvent, DdcOptions, FilterOptions, SourceOptions, UiOptions, UserOptions,} from "./types.ts";import { defaultSourceOptions } from "./base/source.ts";
// where// T: Object// partialMerge: PartialMerge// partialMerge(partialMerge(a, b), c) === partialMerge(a, partialMerge(b, c))type PartialMerge<T> = (a: Partial<T>, b: Partial<T>) => Partial<T>;type Merge<T> = (a: T, b: Partial<T>) => T;type Default<T> = () => T;
function partialOverwrite<T>(a: Partial<T>, b: Partial<T>): Partial<T> { return { ...a, ...b };}
function overwrite<T>(a: T, b: Partial<T>): T { return { ...a, ...b };}export const mergeUiOptions: Merge<UiOptions> = overwrite;export const mergeSourceOptions: Merge<SourceOptions> = overwrite;export const mergeFilterOptions: Merge<FilterOptions> = overwrite;export const mergeUiParams: Merge<BaseUiParams> = overwrite;export const mergeSourceParams: Merge<BaseSourceParams> = overwrite;export const mergeFilterParams: Merge<BaseFilterParams> = overwrite;
export type ContextCallback = | string | ((denops: Denops) => Promise<Partial<DdcOptions>>);
export type ContextCallbacks = { global: ContextCallback; filetype: Record<string, ContextCallback>; buffer: Record<number, ContextCallback>;};
export function foldMerge<T>( merge: Merge<T>, def: Default<T>, partials: (null | undefined | Partial<T>)[],): T { return partials.map((x) => x || {}).reduce(merge, def());}
export function defaultDdcOptions(): DdcOptions { return { autoCompleteDelay: 0, autoCompleteEvents: [ "InsertEnter", "TextChangedI", "TextChangedP", ], backspaceCompletion: false, cmdlineSources: [], filterOptions: {}, filterParams: {}, keywordPattern: "\\k*", postFilters: [], sourceOptions: {}, sourceParams: {}, sources: [], specialBufferCompletion: false, ui: "", uiOptions: {}, uiParams: {}, };}
export function defaultDummy(): Record<string, unknown> { return {};}
function migrateEachKeys<T>( merge: PartialMerge<T>, a: null | undefined | Record<string, Partial<T>>, b: null | undefined | Record<string, Partial<T>>,): null | Record<string, Partial<T>> { if (!a && !b) return null; const ret: Record<string, Partial<T>> = {}; if (a) { for (const key in a) { ret[key] = a[key]; } } if (b) { for (const key in b) { if (key in ret) { ret[key] = merge(ret[key], b[key]); } else { ret[key] = b[key]; } } } return ret;}
export function mergeDdcOptions( a: DdcOptions, b: Partial<DdcOptions>,): DdcOptions { const overwritten: DdcOptions = overwrite(a, b); const partialMergeUiOptions = partialOverwrite; const partialMergeUiParams = partialOverwrite; const partialMergeSourceOptions = partialOverwrite; const partialMergeSourceParams = partialOverwrite; const partialMergeFilterOptions = partialOverwrite; const partialMergeFilterParams = partialOverwrite; return Object.assign(overwritten, { uiOptions: migrateEachKeys( partialMergeUiOptions, a.uiOptions, b.uiOptions, ) || {}, sourceOptions: migrateEachKeys( partialMergeSourceOptions, a.sourceOptions, b.sourceOptions, ) || {}, filterOptions: migrateEachKeys( partialMergeFilterOptions, a.filterOptions, b.filterOptions, ) || {}, uiParams: migrateEachKeys( partialMergeUiParams, a.uiParams, b.uiParams, ) || {}, sourceParams: migrateEachKeys( partialMergeSourceParams, a.sourceParams, b.sourceParams, ) || {}, filterParams: migrateEachKeys( partialMergeFilterParams, a.filterParams, b.filterParams, ) || {}, });}
function patchDdcOptions( a: Partial<DdcOptions>, b: Partial<DdcOptions>,): Partial<DdcOptions> { const overwritten: Partial<DdcOptions> = { ...a, ...b };
const uo = migrateEachKeys( partialOverwrite, a.uiOptions, b.uiOptions, ); if (uo) overwritten.uiOptions = uo; const so = migrateEachKeys( partialOverwrite, a.sourceOptions, b.sourceOptions, ); if (so) overwritten.sourceOptions = so; const fo = migrateEachKeys( partialOverwrite, a.filterOptions, b.filterOptions, ); if (fo) overwritten.filterOptions = fo;
const up = migrateEachKeys(partialOverwrite, a.uiParams, b.uiParams); if (up) overwritten.uiParams = up; const sp = migrateEachKeys(partialOverwrite, a.sourceParams, b.sourceParams); if (sp) overwritten.sourceParams = sp; const fp = migrateEachKeys(partialOverwrite, a.filterParams, b.filterParams); if (fp) overwritten.filterParams = fp;
return overwritten;}
// Customization by end usersclass Custom { global: Partial<DdcOptions> = {}; filetype: Record<string, Partial<DdcOptions>> = {}; context: ContextCallbacks = { global: "", filetype: {}, buffer: {}, }; buffer: Record<number, Partial<DdcOptions>> = {};
async get( denops: Denops | null, ft: string, bufnr: number, options: UserOptions, ): Promise<DdcOptions> { const callContextCallback = async (callback: ContextCallback) => { if (!denops || !callback) { return {}; }
if (is.String(callback)) { if (callback === "") { return {}; }
return await denops.call( "denops#callback#call", callback, ) as Partial<DdcOptions>; } else { return await callback(denops); } };
const contextGlobal = await callContextCallback(this.context.global); const filetype = this.filetype[ft] || {}; const contextFiletype = await callContextCallback( this.context.filetype[ft], ); const buffer = this.buffer[bufnr] || {}; const contextBuffer = await callContextCallback(this.context.buffer[bufnr]);
return foldMerge(mergeDdcOptions, defaultDdcOptions, [ this.global, contextGlobal, filetype, contextFiletype, buffer, contextBuffer, options, ]); }
setGlobal(options: Partial<DdcOptions>): Custom { this.global = options; return this; } setFiletype(ft: string, options: Partial<DdcOptions>): Custom { this.filetype[ft] = options; return this; } setBuffer(bufnr: number, options: Partial<DdcOptions>): Custom { this.buffer[bufnr] = options; return this; } setContextGlobal(callback: ContextCallback): Custom { this.context.global = callback; return this; } setContextFiletype(callback: ContextCallback, ft: string): Custom { this.context.filetype[ft] = callback; return this; } setContextBuffer(callback: ContextCallback, bufnr: number): Custom { this.context.buffer[bufnr] = callback; return this; } patchGlobal(options: Partial<DdcOptions>): Custom { this.global = patchDdcOptions(this.global, options); return this; } patchFiletype(ft: string, options: Partial<DdcOptions>): Custom { this.filetype[ft] = patchDdcOptions(this.filetype[ft] || {}, options); return this; } patchBuffer(bufnr: number, options: Partial<DdcOptions>): Custom { this.buffer[bufnr] = patchDdcOptions(this.buffer[bufnr] || {}, options); return this; }}
// Schema of the state of buffers, etctype World = { bufnr: number; changedByCompletion: boolean; changedTick: number; cursor: (number | undefined)[]; event: DdcEvent; filetype: string; input: string; isLmap: boolean; isPaste: boolean; lineNr: number; mode: string; nextInput: string; wildMenuMode: number;};
function initialWorld(): World { return { bufnr: 0, changedByCompletion: false, changedTick: 0, cursor: [], event: "Manual", filetype: "", input: "", isLmap: false, isPaste: false, lineNr: 0, mode: "", nextInput: "", wildMenuMode: 0, };}
async function _call<T>(denops: Denops, f: string, def: T): Promise<T> { if (await fn.exists(denops, "*" + f)) { return denops.call(f) as Promise<T>; } else { return def; }}
// Fetches current stateasync function cacheWorld(denops: Denops, event: DdcEvent): Promise<World> { const changedByCompletionPromise: Promise<boolean> = (async () => { const completedItem = (await vars.v.get(denops, "completed_item")) as Record<string, unknown>; return event === "TextChangedP" && Object.keys(completedItem).length !== 0; })();
type ContextFiletype = "context_filetype" | "treesitter" | "none";
const filetypePromise: Promise<string> = (async () => { const contextFiletype = await (vars.g.get(denops, "ddc#_context_filetype", "none") as Promise< ContextFiletype >);
if (contextFiletype === "context_filetype") { const context = await _call(denops, "context_filetype#get_filetype", ""); if (context !== "") return context; } else if (contextFiletype === "treesitter") { const context = await denops.call("ddc#syntax#lang") as string; if (context !== "") return context; }
return ensure(await op.filetype.getLocal(denops), is.String); })();
const [ bufnr, changedTick, cursor, enabledEskk, enabledSkkeleton, iminsert, isPaste, lineNr, wildMenuMode, ] = await collect(denops, (denops) => [ fn.bufnr(denops), vars.b.get(denops, "changedtick") as Promise<number>, fn.getcurpos(denops), _call(denops, "eskk#is_enabled", false), _call(denops, "skkeleton#is_enabled", false), op.iminsert.getLocal(denops), op.paste.get(denops), fn.line(denops, "."), fn.wildmenumode(denops) as Promise<number>, ]);
// NOTE: don't use collect() for them. // Other plugins may change the state. const mode: string = event === "InsertEnter" ? "i" : ensure(await fn.mode(denops), is.String); const filetype = await filetypePromise; const changedByCompletion = await changedByCompletionPromise; const input = await denops.call("ddc#util#get_input", event) as string; const nextInput = await denops.call( "ddc#util#get_next_input", event, ) as string;
return { bufnr, changedByCompletion, changedTick, cursor, event, filetype, input, isLmap: !enabledEskk && !enabledSkkeleton && iminsert === 1, isPaste, lineNr, mode, nextInput, wildMenuMode, };}
// is neglect-ablefunction isNegligible(older: World, newer: World): boolean { return older.bufnr === newer.bufnr && older.filetype === newer.filetype && older.input === newer.input && older.event === newer.event;}
export class ContextBuilder { private lastWorld: World = initialWorld(); private custom: Custom = new Custom();
// Re-export for denops.dispatcher async _cacheWorld(denops: Denops, event: DdcEvent): Promise<World> { return await cacheWorld(denops, event); }
async createContext( denops: Denops, event: DdcEvent, options: UserOptions = {}, ): Promise<[boolean, Context, DdcOptions]> { const world = await this._cacheWorld(denops, event); const old = this.lastWorld; this.lastWorld = world; let skip = false; const skipNegligible = event !== "Initialize" && event !== "Manual" && event !== "Update" && event !== "CompleteDone" && isNegligible(old, world); if ( skipNegligible || world.isLmap || world.isPaste || world.changedByCompletion || (world.mode === "c" && world.wildMenuMode) ) { skip = true; }
const context = { changedTick: world.changedTick, cursor: world.cursor, event: event, filetype: world.filetype, input: world.input, lineNr: world.lineNr, mode: world.mode, nextInput: world.nextInput, };
const userOptions = await this._getUserOptions(denops, world, options);
await this.validate(denops, "options", userOptions, defaultDdcOptions()); for (const key in userOptions.sourceOptions) { await this.validate( denops, "sourceOptions", userOptions.sourceOptions[key], defaultSourceOptions(), ); }
if (context.mode === "c") { // Use cmdlineSources instead if (Array.isArray(userOptions.cmdlineSources)) { userOptions.sources = userOptions.cmdlineSources; } else if (is.Record(userOptions.cmdlineSources)) { const cmdType = await fn.getcmdtype(denops) as string; if (userOptions.cmdlineSources[cmdType]) { userOptions.sources = userOptions.cmdlineSources[cmdType]; } } }
return [ skip, context, userOptions, ]; }
async _getUserOptions( denops: Denops, world: World, options: UserOptions = {}, ): Promise<DdcOptions> { return await this.custom.get( denops, world.filetype, world.bufnr, options, ); }
getGlobal(): Partial<DdcOptions> { return this.custom.global; } getFiletype(): Record<string, Partial<DdcOptions>> { return this.custom.filetype; } getContext(): ContextCallbacks { return this.custom.context; } getBuffer(): Record<number, Partial<DdcOptions>> { return this.custom.buffer; } async getCurrent(denops: Denops): Promise<DdcOptions> { const world = await this._cacheWorld(denops, "Manual"); return this._getUserOptions(denops, world); }
async validate( denops: Denops, name: string, options: Record<string, unknown>, defaults: Record<string, unknown>, ) { for (const key in options) { if (!(key in defaults)) { await denops.call( "ddc#util#print_error", `Invalid ${name}: "${key}"`, ); } } }
setGlobal(options: Partial<DdcOptions>) { this.custom.setGlobal(options); } setFiletype(ft: string, options: Partial<DdcOptions>) { this.custom.setFiletype(ft, options); } setBuffer(bufnr: number, options: Partial<DdcOptions>) { this.custom.setBuffer(bufnr, options); } setContextGlobal(callback: ContextCallback) { this.custom.setContextGlobal(callback); } setContextFiletype(callback: ContextCallback, ft: string) { this.custom.setContextFiletype(callback, ft); } setContextBuffer(callback: ContextCallback, bufnr: number) { this.custom.setContextBuffer(callback, bufnr); }
patchGlobal(options: Partial<DdcOptions>) { this.custom.patchGlobal(options); } patchFiletype(ft: string, options: Partial<DdcOptions>) { this.custom.patchFiletype(ft, options); } patchBuffer(bufnr: number, options: Partial<DdcOptions>) { this.custom.patchBuffer(bufnr, options); }}
Deno.test("isNegligible", () => { assertEquals(true, isNegligible(initialWorld(), initialWorld())); assertEquals( isNegligible( { ...initialWorld(), input: "a" }, { ...initialWorld(), input: "ab" }, ), false, );});
Deno.test("patchDdcOptions", () => { const custom = (new Custom()) .setGlobal({ sources: ["around"], sourceParams: { "around": { maxSize: 300, }, }, }) .patchGlobal({ sources: ["around", "baz"], sourceParams: { "baz": { foo: "bar", }, }, }) .patchFiletype("markdown", { filterParams: { "hoge": { foo: "bar", }, }, }) .patchFiletype("cpp", { filterParams: { "hoge": { foo: "bar", }, }, }) .patchFiletype("cpp", { filterParams: { "hoge": { foo: "baz", alice: "bob", }, }, }); assertEquals(custom.global, { sources: ["around", "baz"], sourceParams: { "around": { maxSize: 300, }, "baz": { foo: "bar", }, }, }); assertEquals(custom.filetype, { markdown: { filterParams: { "hoge": { foo: "bar", }, }, }, cpp: { filterParams: { "hoge": { foo: "baz", alice: "bob", }, }, }, });});
Deno.test("mergeDdcOptions", async () => { const custom = (new Custom()) .setGlobal({ sources: ["around"], sourceParams: { "around": { maxSize: 300, }, }, }) .setFiletype("typescript", { sources: [], filterParams: { "matcher_head": { foo: 2, }, }, }) .setBuffer(1, { sources: ["around", "foo"], filterParams: { "matcher_head": { foo: 3, }, "foo": { max: 200, }, }, }) .patchBuffer(2, {}); assertEquals(await custom.get(null, "typescript", 1, {}), { ...defaultDdcOptions(), sources: ["around", "foo"], sourceOptions: {}, filterOptions: {}, sourceParams: { "around": { maxSize: 300, }, }, filterParams: { "matcher_head": { foo: 3, }, "foo": { max: 200, }, }, }); assertEquals(await custom.get(null, "typescript", 2, {}), { ...defaultDdcOptions(), sources: [], sourceOptions: {}, filterOptions: {}, sourceParams: { "around": { maxSize: 300, }, }, filterParams: { "matcher_head": { foo: 2, }, }, }); assertEquals(await custom.get(null, "cpp", 1, {}), { ...defaultDdcOptions(), sources: ["around", "foo"], sourceOptions: {}, filterOptions: {}, sourceParams: { "around": { maxSize: 300, }, }, filterParams: { "matcher_head": { foo: 3, }, "foo": { max: 200, }, }, });});