Skip to main content
Module

x/ddc_vim/ddc.ts

Dark deno-powered completion framework for neovim/Vim8
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861
import { Candidate, Context, DdcCandidate, DdcOptions, FilterOptions, SourceOptions,} from "./types.ts";import { defaultDdcOptions, foldMerge, mergeFilterOptions, mergeFilterParams, mergeSourceOptions, mergeSourceParams,} from "./context.ts";import { BaseSource, defaultSourceOptions, defaultSourceParams, GatherCandidatesArguments,} from "./base/source.ts";import { BaseFilter, defaultFilterOptions, defaultFilterParams, FilterArguments,} from "./base/filter.ts";import { assertEquals, autocmd, deadline, DeadlineError, Denops, fn, op, parse, TimeoutError, toFileUrl,} from "./deps.ts";
type DdcResult = { candidates: Candidate[]; completeStr: string; prevInput: string; lineNr: number;};
export class Ddc { private sources: Record<string, BaseSource> = {}; private filters: Record<string, BaseFilter> = {}; private checkPaths: Record<string, boolean> = {}; private prevResults: Record<string, DdcResult> = {}; private events: string[] = []; private prevRuntimepath = "";
private foundSources(names: string[]): BaseSource[] { return names.map((n) => this.sources[n]).filter((v) => v); } private foundFilters(names: string[]): BaseFilter[] { return names.map((n) => this.filters[n]).filter((v) => v); }
private foundInvalidSources(names: string[]): string[] { return names.filter((n) => !this.sources[n]); } private foundInvalidFilters(names: string[]): string[] { return names.filter((n) => !this.filters[n]); }
async registerAutocmd(denops: Denops, events: string[]) { await autocmd.group(denops, "ddc", (helper: autocmd.GroupHelper) => { for (const event of events) { if (!this.events.includes(event)) { helper.define( event as autocmd.AutocmdEvent, "*", `call denops#notify('${denops.name}','onEvent',["${event}"])`, ); this.events.push(event); } } }); }
async registerSource(denops: Denops, path: string, name: string) { if (path in this.checkPaths) { return; }
this.checkPaths[path] = true;
const mod = await import(toFileUrl(path).href); const source = new mod.Source(); source.name = name; this.sources[source.name] = source; if (source.events && source.events.length != 0) { this.registerAutocmd(denops, source.events); } }
async registerFilter(denops: Denops, path: string, name: string) { if (path in this.checkPaths) { return; }
this.checkPaths[path] = true;
const mod = await import(toFileUrl(path).href); const filter = new mod.Filter(); filter.name = name; filter?.apiVersion ? filter.onInit({ denops }) : filter.onInit(denops); this.filters[filter.name] = filter; if (filter.events && filter.events.length != 0) { this.registerAutocmd(denops, filter.events); } }
async autoload(denops: Denops) { const runtimepath = await op.runtimepath.getGlobal(denops); if (runtimepath == this.prevRuntimepath) { return; }
this.prevRuntimepath = runtimepath;
const sources = await fn.globpath( denops, runtimepath, "denops/ddc-sources/*.ts", 1, 1, ) as string[]; const filters = await fn.globpath( denops, runtimepath, "denops/ddc-filters/*.ts", 1, 1, ) as string[]; await Promise.all(sources.map((path) => { this.registerSource(denops, path, parse(path).name); })); await Promise.all(filters.map((path) => { this.registerFilter(denops, path, parse(path).name); })); }
async checkInvalid( denops: Denops, options: DdcOptions, filterNames: string[], ) { // Check invalid sources const invalidSources = this.foundInvalidSources(options.sources); if (Object.keys(this.sources).length != 0 && invalidSources.length != 0) { await denops.call( "ddc#util#print_error", "Invalid sources are detected!", ); await denops.call("ddc#util#print_error", invalidSources); }
// Check invalid filters const invalidFilters = this.foundInvalidFilters([...new Set(filterNames)]); if (Object.keys(this.filters).length != 0 && invalidFilters.length != 0) { await denops.call( "ddc#util#print_error", "Invalid filters are detected!", ); await denops.call("ddc#util#print_error", invalidFilters); } }
async onEvent( denops: Denops, context: Context, options: DdcOptions, ): Promise<void> { let filterNames: string[] = []; for (const source of this.foundSources(options.sources)) { const [sourceOptions, sourceParams] = sourceArgs(options, source); if (source.events?.includes(context.event)) { await callSourceOnEvent( source, denops, context, options, sourceOptions, sourceParams, ); }
filterNames = filterNames.concat( sourceOptions.matchers, sourceOptions.sorters, sourceOptions.converters, ); }
// Uniq. filterNames = [...new Set(filterNames)];
for (const filter of this.foundFilters(filterNames)) { if (filter.events?.includes(context.event)) { const [o, p] = filterArgs( options.filterOptions, options.filterParams, filter, ); await callFilterOnEvent( filter, denops, context, options, o, p, ); } }
if ( Object.keys(this.sources).length != 0 && Object.keys(this.filters).length != 0 ) { await this.checkInvalid(denops, options, filterNames); } }
async gatherResults( denops: Denops, context: Context, options: DdcOptions, ): Promise<[number, DdcCandidate[]]> { const sources = this.foundSources(options.sources) .map((s) => [s, ...sourceArgs(options, s)] as const); const rs = await Promise.all(sources.map(async ([s, o, p]) => { const pos = await callSourceGetCompletePosition( s, denops, context, options, o, p, ); const forceCompletion = o.forceCompletionPattern.length != 0 && context.input.search( new RegExp("(" + o.forceCompletionPattern + ")$"), ) != -1; // Note: If forceCompletion and not matched getCompletePosition(), // Use cursor position instead. const completePos = (pos < 0 && forceCompletion) ? context.input.length : (s.isBytePos && pos >= 0) ? byteposToCharpos(context.input, pos) : pos; const completeStr = context.input.slice(completePos); if ( completePos < 0 || (!forceCompletion && context.event != "Manual" && context.event != "ManualRefresh" && (completeStr.length < o.minAutoCompleteLength || completeStr.length > o.maxAutoCompleteLength)) ) { delete this.prevResults[s.name]; return; }
// Check previous result. const result = s.name in this.prevResults ? this.prevResults[s.name] : null;
const prevInput = context.input.slice(0, completePos);
if ( !result || prevInput != result.prevInput || !completeStr.startsWith(result.completeStr) || context.lineNr != result.lineNr || context.event == "Manual" || context.event == "AutoRefresh" || context.event == "ManualRefresh" || o.isVolatile ) { // Not matched. const scs = await callSourceGatherCandidates( s, denops, context, options, o, p, completeStr, ); if (!scs.length) { return; }
this.prevResults[s.name] = { candidates: scs.concat(), completeStr: completeStr, prevInput: prevInput, lineNr: context.lineNr, }; }
const fcs = await this.filterCandidates( denops, context, options, o, options.filterOptions, options.filterParams, completeStr, this.prevResults[s.name].candidates, );
const candidates = fcs.map((c) => ( { ...c, abbr: formatAbbr(c.word, c.abbr), source: s.name, dup: o.dup, icase: true, equal: true, menu: formatMenu(o.mark, c.menu), } )); if (!candidates.length) { return; } return [completePos, candidates] as const; }));
// Remove invalid source const fs = rs.filter(<T>(v?: T): v is T => !!v); if (!fs.length) { return [-1, []]; }
const completePos = Math.min(...fs.map((v) => v[0]));
// Flatten candidates // Todo: Merge candidates by completePos const candidates = fs.flatMap(([_, cs]) => cs);
// Convert2byte for Vim const completePosBytes = charposToBytepos(context.input, completePos);
return [completePosBytes, candidates]; }
private async filterCandidates( denops: Denops, context: Context, options: DdcOptions, sourceOptions: SourceOptions, filterOptions: Record<string, Partial<FilterOptions>>, filterParams: Record<string, Partial<Record<string, unknown>>>, completeStr: string, cdd: Candidate[], ): Promise<Candidate[]> { const matchers = this.foundFilters(sourceOptions.matchers); const sorters = this.foundFilters(sourceOptions.sorters); const converters = this.foundFilters(sourceOptions.converters);
async function callFilters(filters: BaseFilter[]): Promise<Candidate[]> { for (const filter of filters) { const [o, p] = filterArgs(filterOptions, filterParams, filter); cdd = await callFilterFilter( filter, denops, context, options, sourceOptions, o, p, completeStr, cdd, ); }
return cdd; }
if (sourceOptions.matcherKey != "") { cdd = cdd.map((c) => ( { ...c, // @ts-ignore: Convert matcherKey word: c[sourceOptions.matcherKey], __word: c.word, } )); }
cdd = await callFilters(matchers);
if (sourceOptions.matcherKey != "") { cdd = cdd.map((c) => ( { ...c, // @ts-ignore: Restore matcherKey word: c.__word, } )); }
cdd = await callFilters(sorters);
// Filter by maxCandidates cdd = cdd.slice(0, sourceOptions.maxCandidates);
cdd = await callFilters(converters);
return cdd; }}
function formatAbbr(word: string, abbr: string | undefined): string { return abbr ? abbr : word;}
function formatMenu(prefix: string, menu: string | undefined): string { menu = menu ? menu : ""; return prefix == "" ? menu : menu == "" ? `[${prefix}]` : `[${prefix}] ${menu}`;}
function byteposToCharpos(input: string, pos: number): number { const bytes = (new TextEncoder()).encode(input); return (new TextDecoder()).decode(bytes.slice(0, pos)).length;}
function charposToBytepos(input: string, pos: number): number { return (new TextEncoder()).encode(input.slice(0, pos)).length;}
function sourceArgs( options: DdcOptions, source: BaseSource,): [SourceOptions, Record<string, unknown>] { const o = foldMerge( mergeSourceOptions, defaultSourceOptions, [options.sourceOptions["_"], options.sourceOptions[source.name]], ); const p = foldMerge(mergeSourceParams, defaultSourceParams, [ source.params(), options.sourceParams[source.name], ]); return [o, p];}
function filterArgs( filterOptions: Record<string, Partial<FilterOptions>>, filterParams: Record<string, Partial<Record<string, unknown>>>, filter: BaseFilter,): [FilterOptions, Record<string, unknown>] { // TODO: '_'? const optionsOf = (filter: BaseFilter) => foldMerge(mergeFilterOptions, defaultFilterOptions, [ filterOptions[filter.name], ]); const paramsOf = (filter: BaseFilter) => foldMerge(mergeFilterParams, defaultFilterParams, [ filter.params(), filterParams[filter.name], ]); return [optionsOf(filter), paramsOf(filter)];}
async function checkSourceOnInit( source: BaseSource, denops: Denops, sourceOptions: SourceOptions, sourceParams: Record<string, unknown>,) { if (source.isInitialized) { return; }
try { (source?.apiVersion) ? await source.onInit({ denops, sourceOptions, sourceParams, }) : await source.onInit( // @ts-ignore: For deprecated sources denops, );
source.isInitialized = true; } catch (e: unknown) { if (e instanceof TimeoutError) { // Ignore timeout error } else { console.error( `[ddc.vim] source: ${source.name} "onInit()" is failed`, ); console.error(e); } }}
async function checkFilterOnInit( filter: BaseFilter, denops: Denops, filterOptions: FilterOptions, filterParams: Record<string, unknown>,) { if (filter.isInitialized) { return; }
try { (filter?.apiVersion) ? await filter.onInit({ denops, filterOptions, filterParams, }) : await filter.onInit( // @ts-ignore: For deprecated sources denops, );
filter.isInitialized = true; } catch (e: unknown) { if (e instanceof TimeoutError) { // Ignore timeout error } else { console.error( `[ddc.vim] filter: ${filter.name} "onInit()" is failed`, ); console.error(e); } }}
async function callSourceOnEvent( source: BaseSource, denops: Denops, context: Context, options: DdcOptions, sourceOptions: SourceOptions, sourceParams: Record<string, unknown>,) { await checkSourceOnInit(source, denops, sourceOptions, sourceParams);
try { (source?.apiVersion) ? await source.onEvent({ denops, context, options, sourceOptions, sourceParams, }) : await source.onEvent( denops, // @ts-ignore: For deprecated sources context, options, sourceOptions, sourceParams, ); } catch (e: unknown) { if (e instanceof TimeoutError) { // Ignore timeout error } else { console.error( `[ddc.vim] source: ${source.name} "onEvent()" is failed`, ); console.error(e); } }}
async function callFilterOnEvent( filter: BaseFilter, denops: Denops, context: Context, options: DdcOptions, filterOptions: FilterOptions, filterParams: Record<string, unknown>,) { await checkFilterOnInit(filter, denops, filterOptions, filterParams);
try { (filter?.apiVersion) ? filter.onEvent({ denops, context, options, filterOptions, filterParams, }) : filter.onEvent( denops, // @ts-ignore: For deprecated sources context, options, filterOptions, filterParams, ); } catch (e: unknown) { if (e instanceof TimeoutError) { // Ignore timeout error } else { console.error( `[ddc.vim] filter: ${filter.name} "onEvent()" is failed`, ); console.error(e); } }}
async function callSourceGetCompletePosition( source: BaseSource, denops: Denops, context: Context, options: DdcOptions, sourceOptions: SourceOptions, sourceParams: Record<string, unknown>,): Promise<number> { await checkSourceOnInit(source, denops, sourceOptions, sourceParams);
try { return (source?.apiVersion) ? await source.getCompletePosition({ denops, context, options, sourceOptions, sourceParams, }) : await source.getCompletePosition( denops, // @ts-ignore: For deprecated sources context, options, sourceOptions, sourceParams, ); } catch (e: unknown) { if (e instanceof TimeoutError) { // Ignore timeout error } else { console.error( `[ddc.vim] source: ${source.name} "getCompletePoistion()" is failed`, ); console.error(e); }
return -1; }}
async function callSourceGatherCandidates( source: BaseSource, denops: Denops, context: Context, options: DdcOptions, sourceOptions: SourceOptions, sourceParams: Record<string, unknown>, completeStr: string,): Promise<Candidate[]> { await checkSourceOnInit(source, denops, sourceOptions, sourceParams);
try { const promise = (source?.apiVersion) ? source.gatherCandidates({ denops, context, options, sourceOptions, sourceParams, completeStr, }) : source.gatherCandidates( denops, // @ts-ignore: For deprecated sources context, options, sourceOptions, sourceParams, completeStr, ); return await deadline(promise, sourceOptions.timeout); } catch (e: unknown) { if (e instanceof TimeoutError || e instanceof DeadlineError) { // Ignore timeout error } else { console.error( `[ddc.vim] source: ${source.name} "gatherCandidates()" is failed`, ); console.error(e); }
return []; }}
async function callFilterFilter( filter: BaseFilter, denops: Denops, context: Context, options: DdcOptions, sourceOptions: SourceOptions, filterOptions: FilterOptions, filterParams: Record<string, unknown>, completeStr: string, candidates: Candidate[],): Promise<Candidate[]> { await checkFilterOnInit(filter, denops, filterOptions, filterParams);
try { return (filter?.apiVersion) ? await filter.filter({ denops, context, options, sourceOptions, filterOptions, filterParams, completeStr, candidates, }) : await filter.filter( denops, // @ts-ignore: For deprecated sources context, options, filterOptions, filterParams, completeStr, candidates, ); } catch (e: unknown) { if (e instanceof TimeoutError) { // Ignore timeout error } else { console.error( `[ddc.vim] filter: ${filter.name} "filter()" is failed`, ); console.error(e); }
return []; }}
Deno.test("sourceArgs", () => { const userOptions: DdcOptions = { ...defaultDdcOptions(), sources: ["strength"], sourceOptions: { "_": { mark: "A", matchers: ["matcher_head"], }, "strength": { mark: "S", }, }, sourceParams: { "_": { "by_": "bar", }, "strength": { min: 100, }, }, }; class S extends BaseSource { params() { return { "min": 0, "max": 999, }; } gatherCandidates( _args: GatherCandidatesArguments | Denops, _context?: Context, _options?: DdcOptions, _sourceOptions?: SourceOptions, _sourceParams?: Record<string, unknown>, _completeStr?: string, ): Promise<Candidate[]> { return Promise.resolve([]); } } const source = new S(); source.name = "strength"; const [o, p] = sourceArgs(userOptions, source); assertEquals(o, { ...defaultSourceOptions(), mark: "S", matchers: ["matcher_head"], maxCandidates: 500, converters: [], sorters: [], }); assertEquals(p.by_, undefined); assertEquals(p, { ...defaultSourceParams(), min: 100, max: 999, });});
Deno.test("filterArgs", () => { const userOptions: Record<string, FilterOptions> = { "/dev/null": { placeholder: undefined, }, }; const userParams: Record<string, Record<string, unknown>> = { "/dev/null": { min: 100, }, }; class F extends BaseFilter { params() { return { "min": 0, "max": 999, }; } filter( _args: FilterArguments | Denops, _context?: Context, _options?: DdcOptions, _sourceOptions?: SourceOptions, _filterOptions?: FilterOptions, _filterParams?: Record<string, unknown>, _completeStr?: string, _candidates?: Candidate[], ): Promise<Candidate[]> { return Promise.resolve([]); } } const filter = new F(); filter.name = "/dev/null"; assertEquals(filterArgs(userOptions, userParams, filter), [{ ...defaultFilterOptions(), }, { ...defaultFilterParams(), min: 100, max: 999, }]);});
Deno.test("byteposToCharpos", () => { assertEquals(byteposToCharpos("あ hoge", 4), 2);});
Deno.test("charposToBytepos", () => { assertEquals(charposToBytepos("あ hoge", 2), 4);});