From 536a6e98c017687d1263a16c934b43f0605c1414 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:39:16 +0000 Subject: [PATCH 1/2] perf: reduce CPU usage during rapid typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.ts: skip onEvent() call when event is negligible (改善A) Move source/filter onEvent dispatch after the skip guard so it is not executed for redundant TextChangedI events where input has not actually changed. cbContext.revoke() is also moved to the correct position (after the skip guard, before onEvent). - context.ts: validate options only on mutation (改善B) Add #needsValidation flag to ContextBuilderImpl. All set*/patch* methods set the flag; createContext runs #validate() only when the flag is true, then clears it. This avoids iterating every option key on every keystroke. - ddc.ts: TextEncoder/TextDecoder module-level singletons (改善C) byteposToCharpos() and charposToBytepos() previously allocated new TextEncoder/TextDecoder instances on every call. Reuse a single pair of instances at module scope. - ddc.ts: fix cacheTimeout time source (改善G) Date#getSeconds() returns 0-59 and wraps, causing the minute boundary to reset the comparison and prevent cache expiry. Switch to Math.floor(Date.now()/1000) for a monotonically growing seconds-since-epoch value. - ext.ts: eliminate duplicate getFilter() calls (改善E) runMatchersConcurrently() resolved filters once for the parallelSafe check but then passed the original UserFilter names back to callFilters(), which resolved them again. Refactor to a callResolvedFilters() helper that operates on pre-resolved tuples and reuse those across all branches and chunks. Agent-Logs-Url: https://github.com/Shougo/ddc.vim/sessions/b543f2b0-5b9e-4f7c-84ce-a7e4942a8f2c Co-authored-by: Shougo <41495+Shougo@users.noreply.github.com> --- denops/ddc/app.ts | 8 +++--- denops/ddc/context.ts | 30 ++++++++++++++------ denops/ddc/ddc.ts | 13 +++++---- denops/ddc/ext.ts | 64 +++++++++++++++++++++++++++++++------------ 4 files changed, 80 insertions(+), 35 deletions(-) diff --git a/denops/ddc/app.ts b/denops/ddc/app.ts index 5090bd9..5f04e0f 100644 --- a/denops/ddc/app.ts +++ b/denops/ddc/app.ts @@ -367,6 +367,10 @@ export const main: Entrypoint = (denops: Denops) => { await ddc.checkManualCompletion(denops, context, options, event); + if (skip) return; + + cbContext.revoke(); + await onEvent( denops, loader, @@ -375,10 +379,6 @@ export const main: Entrypoint = (denops: Denops) => { options, ); - if (skip) return; - - cbContext.revoke(); - if (await ddc.checkSkipCompletion(denops, context, options)) { return; } diff --git a/denops/ddc/context.ts b/denops/ddc/context.ts index c656952..b105afa 100644 --- a/denops/ddc/context.ts +++ b/denops/ddc/context.ts @@ -398,6 +398,8 @@ function isNegligible(older: World, newer: World): boolean { export class ContextBuilderImpl implements ContextBuilder { #lastWorld: World = initialWorld(); #custom: Custom = new Custom(); + // Set to true whenever options are mutated; cleared after first validation. + #needsValidation = true; async createContext( denops: Denops, @@ -431,14 +433,17 @@ export class ContextBuilderImpl implements ContextBuilder { 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 (this.#needsValidation) { + this.#needsValidation = false; + 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") { @@ -520,31 +525,40 @@ export class ContextBuilderImpl implements ContextBuilder { setGlobal(options: Partial) { this.#custom.setGlobal(options); + this.#needsValidation = true; } setFiletype(ft: string, options: Partial) { this.#custom.setFiletype(ft, options); + this.#needsValidation = true; } setBuffer(bufnr: number, options: Partial) { this.#custom.setBuffer(bufnr, options); + this.#needsValidation = true; } setContextGlobal(callback: Callback) { this.#custom.setContextGlobal(callback); + this.#needsValidation = true; } setContextFiletype(callback: Callback, ft: string) { this.#custom.setContextFiletype(callback, ft); + this.#needsValidation = true; } setContextBuffer(callback: Callback, bufnr: number) { this.#custom.setContextBuffer(callback, bufnr); + this.#needsValidation = true; } patchGlobal(options: Partial) { this.#custom.patchGlobal(options); + this.#needsValidation = true; } patchFiletype(ft: string, options: Partial) { this.#custom.patchFiletype(ft, options); + this.#needsValidation = true; } patchBuffer(bufnr: number, options: Partial) { this.#custom.patchBuffer(bufnr, options); + this.#needsValidation = true; } } diff --git a/denops/ddc/ddc.ts b/denops/ddc/ddc.ts index 1affbf5..f468bbf 100644 --- a/denops/ddc/ddc.ts +++ b/denops/ddc/ddc.ts @@ -35,6 +35,9 @@ import { batch } from "@denops/std/batch"; import { assertEquals } from "@std/assert/equals"; +const _encoder = new TextEncoder(); +const _decoder = new TextDecoder(); + type DdcResult = { items: Item[]; completePos: number; @@ -241,7 +244,7 @@ export class Ddc { completeStr.length > o.maxAutoCompleteLength); // Check cache timeout. - const currentTime = new Date().getSeconds(); + const currentTime = Math.floor(Date.now() / 1000); if ( o.cacheTimeout > 0 && this.#prevResults[s.name] && currentTime > this.#prevResults[s.name].time + o.cacheTimeout @@ -342,7 +345,7 @@ export class Ddc { completeStr, lineNr: context.lineNr, isIncomplete, - time: currentTime, + time: Math.floor(Date.now() / 1000), }; } @@ -731,12 +734,12 @@ function formatMenu(prefix: string, menu: string | undefined): string { } function byteposToCharpos(input: string, pos: number): number { - const bytes = (new TextEncoder()).encode(input); - return (new TextDecoder()).decode(bytes.slice(0, pos)).length; + const bytes = _encoder.encode(input); + return _decoder.decode(bytes.slice(0, pos)).length; } function charposToBytepos(input: string, pos: number): number { - return (new TextEncoder()).encode(input.slice(0, pos)).length; + return _encoder.encode(input.slice(0, pos)).length; } Deno.test("byteposToCharpos", () => { diff --git a/denops/ddc/ext.ts b/denops/ddc/ext.ts index ecf355f..59c94de 100644 --- a/denops/ddc/ext.ts +++ b/denops/ddc/ext.ts @@ -204,21 +204,18 @@ export async function filterItems( completeStr: string, cdd: Item[], ): Promise { - // Run a list of filters sequentially on the given items array. - async function callFilters( - userFilters: UserFilter[], + type ResolvedFilter = [ + BaseFilter, + FilterOptions, + BaseParams, + ]; + + // Run a list of pre-resolved filters sequentially on the given items array. + async function callResolvedFilters( + resolved: ResolvedFilter[], items: Item[], ): Promise { - for (const userFilter of userFilters) { - const [filter, filterOptions, filterParams] = await getFilter( - denops, - loader, - options, - userFilter, - ); - if (!filter) { - return []; - } + for (const [filter, filterOptions, filterParams] of resolved) { items = await callFilterFilter( filter, denops, @@ -232,10 +229,30 @@ export async function filterItems( items, ); } - return items; } + // Run a list of filters sequentially on the given items array. + async function callFilters( + userFilters: UserFilter[], + items: Item[], + ): Promise { + const resolved: ResolvedFilter[] = []; + for (const userFilter of userFilters) { + const [filter, filterOptions, filterParams] = await getFilter( + denops, + loader, + options, + userFilter, + ); + if (!filter) { + return []; + } + resolved.push([filter, filterOptions, filterParams]); + } + return callResolvedFilters(resolved, items); + } + // Run matchers concurrently when all of them declare parallelSafe = true and // matcherConcurrency > 1. Falls back to sequential execution when any // safety condition is not met or any chunk throws an exception. @@ -250,17 +267,28 @@ export async function filterItems( return callFilters(matcherFilters, items); } - // Check whether every matcher has opted in to parallel execution + // Resolve filters once and reuse across all branches/chunks. const resolvedFilters = await Promise.all( matcherFilters.map((uf) => getFilter(denops, loader, options, uf)), ); + + // Check whether every matcher has opted in to parallel execution const allParallelSafe = resolvedFilters.every( ([filter, filterOptions]) => filter !== undefined && filterOptions.parallelSafe, ); + // If any filter could not be resolved, treat the same as callFilters does: + // return an empty array. + if (resolvedFilters.some(([f]) => f === undefined)) { + return []; + } + const validResolved = resolvedFilters.map(([f, o, p]) => + [f!, o, p] as ResolvedFilter + ); + if (!allParallelSafe) { - return callFilters(matcherFilters, items); + return callResolvedFilters(validResolved, items); } // Split items into (at most concurrency) equal-sized chunks @@ -273,7 +301,7 @@ export async function filterItems( let parallelResults: Item[][] | null = null; try { parallelResults = await Promise.all( - chunks.map((chunk) => callFilters(matcherFilters, chunk)), + chunks.map((chunk) => callResolvedFilters(validResolved, chunk)), ); } catch (_e) { parallelResults = null; @@ -290,7 +318,7 @@ export async function filterItems( } // Fallback: sequential execution - return callFilters(matcherFilters, items); + return callResolvedFilters(validResolved, items); } if (sourceOptions.maxKeywordLength > 0) { From a1b2e68b3b6ebcae08feee19857efd909ebe100f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:40:50 +0000 Subject: [PATCH 2/2] refactor: address code review feedback - app.ts: add comment explaining why cbContext.revoke() sits after the skip guard - ext.ts: replace non-null assertion (!) with a type-predicate filter when narrowing resolved matchers, eliminating the unchecked cast Agent-Logs-Url: https://github.com/Shougo/ddc.vim/sessions/b543f2b0-5b9e-4f7c-84ce-a7e4942a8f2c Co-authored-by: Shougo <41495+Shougo@users.noreply.github.com> --- denops/ddc/app.ts | 2 ++ denops/ddc/ext.ts | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/denops/ddc/app.ts b/denops/ddc/app.ts index 5f04e0f..ab4cfef 100644 --- a/denops/ddc/app.ts +++ b/denops/ddc/app.ts @@ -369,6 +369,8 @@ export const main: Entrypoint = (denops: Denops) => { if (skip) return; + // Revoke any pending callbacks from the previous completion cycle before + // starting a new one. cbContext.revoke(); await onEvent( diff --git a/denops/ddc/ext.ts b/denops/ddc/ext.ts index 59c94de..9e34f7c 100644 --- a/denops/ddc/ext.ts +++ b/denops/ddc/ext.ts @@ -280,12 +280,13 @@ export async function filterItems( // If any filter could not be resolved, treat the same as callFilters does: // return an empty array. - if (resolvedFilters.some(([f]) => f === undefined)) { + const validResolved = resolvedFilters.filter( + (r): r is [BaseFilter, FilterOptions, BaseParams] => + r[0] !== undefined, + ); + if (validResolved.length !== resolvedFilters.length) { return []; } - const validResolved = resolvedFilters.map(([f, o, p]) => - [f!, o, p] as ResolvedFilter - ); if (!allParallelSafe) { return callResolvedFilters(validResolved, items);