@@ -22,6 +22,18 @@ import { sortCandidates } from "./ordering.js";
2222import { parseRule } from "./parser.js" ;
2323import type { LoadedRule , MatchReason , PiRulesConfig , RuleCandidate , RuleDiagnostic , SessionState } from "./types.js" ;
2424
25+ interface LoadedRuleContent {
26+ frontmatter : LoadedRule [ "frontmatter" ] ;
27+ body : string ;
28+ contentHash : string ;
29+ diagnostic ?: string ;
30+ }
31+
32+ type CandidateProjectMembership = Map < string , boolean > ;
33+ type DynamicMatchCache = Map < string , MatchReason | null > ;
34+
35+ const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096 ;
36+
2537export interface EngineDeps {
2638 findCandidates : ( options : {
2739 projectRoot : string | null ;
@@ -33,6 +45,7 @@ export interface EngineDeps {
3345 readFile : ( path : string ) => string | null ;
3446 findProjectRoot : ( startPath : string ) => string | null ;
3547 extractToolPaths : ( event : ToolResultEvent , cwd : string ) => string [ ] ;
48+ matchRule ?: typeof matchRule ;
3649}
3750
3851export interface Engine {
@@ -47,9 +60,9 @@ export interface Engine {
4760 formatDynamic ( rules : ReadonlyArray < LoadedRule > , target : string ) : string ;
4861 resetSession ( cwd ?: string ) : void ;
4962 isStaticInjected ( rule : LoadedRule ) : boolean ;
50- isDynamicInjected ( rule : LoadedRule ) : boolean ;
63+ isDynamicInjected ( scopeKey : string , rule : LoadedRule ) : boolean ;
5164 markStaticInjected ( rule : LoadedRule ) : boolean ;
52- markDynamicInjected ( rule : LoadedRule ) : boolean ;
65+ markDynamicInjected ( scopeKey : string , rule : LoadedRule ) : boolean ;
5366}
5467
5568const ROOT_SINGLE_FILE_SOURCES = new Set ( PROJECT_SINGLE_FILES . filter ( ( source ) => ! source . includes ( "/" ) ) ) ;
@@ -66,6 +79,7 @@ export function defaultConfig(): PiRulesConfig {
6679
6780export function createEngine ( config : PiRulesConfig , deps : EngineDeps ) : Engine {
6881 const state = createSessionState ( ) ;
82+ const dynamicMatchCache : DynamicMatchCache = new Map ( ) ;
6983
7084 function loadStaticRules ( cwd : string ) : { rules : LoadedRule [ ] ; diagnostics : RuleDiagnostic [ ] } {
7185 state . cwd = cwd ;
@@ -96,25 +110,37 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
96110 const rules : LoadedRule [ ] = [ ] ;
97111 const diagnostics : RuleDiagnostic [ ] = [ ] ;
98112 const seenRules = new Set < string > ( ) ;
113+ const loadedRuleContent = new Map < string , LoadedRuleContent | null > ( ) ;
114+ const projectMembership = new Map < string , boolean > ( ) ;
99115 const disabledSources = disabledSourcesFor ( config ) ;
100116
101- for ( const targetFile of targetPaths ) {
117+ for ( const targetFile of uniqueStrings ( targetPaths ) ) {
102118 const projectRoot = deps . findProjectRoot ( targetFile ) ;
103119 const candidates = deps . findCandidates ( { projectRoot, targetFile, disabledSources } ) ;
104120
105121 for ( const candidate of sortCandidates ( candidates ) ) {
106- const loadedRule = loadCandidate ( candidate , deps , diagnostics , projectRoot ) ;
122+ const loadedRule = loadCandidate (
123+ candidate ,
124+ deps ,
125+ diagnostics ,
126+ projectRoot ,
127+ loadedRuleContent ,
128+ projectMembership ,
129+ ) ;
107130 if ( loadedRule === null ) {
108131 continue ;
109132 }
110133
111- const matchResult = matchRule ( {
112- frontmatter : loadedRule . frontmatter ,
113- isSingleFile : candidate . isSingleFile ,
114- pathBases : pathBasesForTarget ( projectRoot , targetFile , candidate ) ,
115- } ) ;
134+ const matchReason = matchDynamicRuleCached (
135+ dynamicMatchCache ,
136+ projectRoot ,
137+ targetFile ,
138+ candidate ,
139+ loadedRule ,
140+ deps . matchRule ?? matchRule ,
141+ ) ;
116142
117- if ( ! matchResult . matched ) {
143+ if ( matchReason === null ) {
118144 continue ;
119145 }
120146
@@ -124,7 +150,7 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
124150 }
125151
126152 seenRules . add ( dedupKey ) ;
127- rules . push ( { ...loadedRule , matchReason : matchResult . reason } ) ;
153+ rules . push ( { ...loadedRule , matchReason } ) ;
128154 }
129155 }
130156
@@ -147,17 +173,73 @@ export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
147173 } ) ,
148174 resetSession : ( cwd ) => {
149175 clearSession ( state ) ;
176+ dynamicMatchCache . clear ( ) ;
150177 if ( cwd !== undefined ) {
151178 state . cwd = cwd ;
152179 }
153180 } ,
154181 isStaticInjected : ( rule ) => isStaticInjectedInState ( state , rule ) ,
155- isDynamicInjected : ( rule ) => isDynamicInjectedInState ( state , rule ) ,
182+ isDynamicInjected : ( scopeKey , rule ) => isDynamicInjectedInState ( state , scopeKey , rule ) ,
156183 markStaticInjected : ( rule ) => markStaticInjectedInState ( state , rule ) ,
157- markDynamicInjected : ( rule ) => markDynamicInjectedInState ( state , rule ) ,
184+ markDynamicInjected : ( scopeKey , rule ) => markDynamicInjectedInState ( state , scopeKey , rule ) ,
158185 } ;
159186}
160187
188+ function matchDynamicRuleCached (
189+ cache : DynamicMatchCache ,
190+ projectRoot : string | null ,
191+ targetFile : string ,
192+ candidate : RuleCandidate ,
193+ loadedRule : LoadedRule ,
194+ matchRuleImpl : typeof matchRule ,
195+ ) : MatchReason | null {
196+ const cacheKey = dynamicMatchCacheKey ( projectRoot , targetFile , candidate , loadedRule . contentHash ) ;
197+ if ( cache . has ( cacheKey ) ) {
198+ const cachedReason = cache . get ( cacheKey ) ?? null ;
199+ cache . delete ( cacheKey ) ;
200+ cache . set ( cacheKey , cachedReason ) ;
201+ return cachedReason ;
202+ }
203+
204+ const matchResult = matchRuleImpl ( {
205+ frontmatter : loadedRule . frontmatter ,
206+ isSingleFile : candidate . isSingleFile ,
207+ pathBases : pathBasesForTarget ( projectRoot , targetFile , candidate ) ,
208+ } ) ;
209+ const reason = matchResult . matched ? matchResult . reason : null ;
210+ setDynamicMatchCacheEntry ( cache , cacheKey , reason ) ;
211+ return reason ;
212+ }
213+
214+ function setDynamicMatchCacheEntry ( cache : DynamicMatchCache , cacheKey : string , reason : MatchReason | null ) : void {
215+ if ( cache . size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES ) {
216+ const oldestCacheKey = cache . keys ( ) . next ( ) . value ;
217+ if ( oldestCacheKey !== undefined ) {
218+ cache . delete ( oldestCacheKey ) ;
219+ }
220+ }
221+ cache . set ( cacheKey , reason ) ;
222+ }
223+
224+ function dynamicMatchCacheKey (
225+ projectRoot : string | null ,
226+ targetFile : string ,
227+ candidate : RuleCandidate ,
228+ contentHash : string ,
229+ ) : string {
230+ return [
231+ projectRoot ?? "" ,
232+ toPosixPath ( resolve ( targetFile ) ) ,
233+ candidate . realPath ,
234+ candidate . relativePath ,
235+ candidate . source ,
236+ candidate . isGlobal ? "global" : "project" ,
237+ candidate . isSingleFile ? "single" : "multi" ,
238+ String ( candidate . distance ) ,
239+ contentHash ,
240+ ] . join ( "\0" ) ;
241+ }
242+
161243function loadStaticCandidates ( candidates : ReadonlyArray < RuleCandidate > , deps : EngineDeps , projectRoot : string | null ) {
162244 const rules : LoadedRule [ ] = [ ] ;
163245 const diagnostics : RuleDiagnostic [ ] = [ ] ;
@@ -193,8 +275,10 @@ function loadCandidate(
193275 deps : EngineDeps ,
194276 diagnostics : RuleDiagnostic [ ] ,
195277 projectRoot : string | null ,
278+ loadedRuleContent ?: Map < string , LoadedRuleContent | null > ,
279+ projectMembership ?: CandidateProjectMembership ,
196280) : ( LoadedRule & { matchReason : MatchReason } ) | null {
197- if ( ! isCandidateWithinProject ( candidate , projectRoot ) ) {
281+ if ( ! isCandidateWithinProjectCached ( candidate , projectRoot , projectMembership ) ) {
198282 diagnostics . push ( {
199283 severity : "warning" ,
200284 source : candidate . path ,
@@ -203,22 +287,48 @@ function loadCandidate(
203287 return null ;
204288 }
205289
290+ const cachedContent = loadedRuleContent ?. get ( candidate . realPath ) ;
291+ if ( cachedContent !== undefined ) {
292+ return loadedRuleFromContent ( candidate , cachedContent , diagnostics ) ;
293+ }
294+
206295 const content = deps . readFile ( candidate . path ) ;
207296 if ( content === null ) {
297+ loadedRuleContent ?. set ( candidate . realPath , null ) ;
208298 diagnostics . push ( { severity : "warning" , source : candidate . path , message : "Unable to read rule file" } ) ;
209299 return null ;
210300 }
211301
212302 const parsed = parseRule ( content ) ;
213- if ( parsed . diagnostic !== undefined ) {
214- diagnostics . push ( { severity : "warning" , source : candidate . path , message : parsed . diagnostic } ) ;
303+ const loadedContent = {
304+ frontmatter : parsed . frontmatter ,
305+ body : parsed . body ,
306+ contentHash : hashContent ( parsed . body ) ,
307+ diagnostic : parsed . diagnostic ,
308+ } satisfies LoadedRuleContent ;
309+ loadedRuleContent ?. set ( candidate . realPath , loadedContent ) ;
310+ return loadedRuleFromContent ( candidate , loadedContent , diagnostics ) ;
311+ }
312+
313+ function loadedRuleFromContent (
314+ candidate : RuleCandidate ,
315+ content : LoadedRuleContent | null ,
316+ diagnostics : RuleDiagnostic [ ] ,
317+ ) : ( LoadedRule & { matchReason : MatchReason } ) | null {
318+ if ( content === null ) {
319+ diagnostics . push ( { severity : "warning" , source : candidate . path , message : "Unable to read rule file" } ) ;
320+ return null ;
321+ }
322+
323+ if ( content . diagnostic !== undefined ) {
324+ diagnostics . push ( { severity : "warning" , source : candidate . path , message : content . diagnostic } ) ;
215325 }
216326
217327 return {
218328 ...candidate ,
219- frontmatter : parsed . frontmatter ,
220- body : parsed . body ,
221- contentHash : hashContent ( parsed . body ) ,
329+ frontmatter : content . frontmatter ,
330+ body : content . body ,
331+ contentHash : content . contentHash ,
222332 matchReason : { kind : "no-match" } ,
223333 } ;
224334}
@@ -240,6 +350,26 @@ function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string
240350 return relativeRealPath === "" || ( ! relativeRealPath . startsWith ( ".." ) && ! isAbsolute ( relativeRealPath ) ) ;
241351}
242352
353+ function isCandidateWithinProjectCached (
354+ candidate : RuleCandidate ,
355+ projectRoot : string | null ,
356+ projectMembership : CandidateProjectMembership | undefined ,
357+ ) : boolean {
358+ if ( projectMembership === undefined ) {
359+ return isCandidateWithinProject ( candidate , projectRoot ) ;
360+ }
361+
362+ const cacheKey = `${ projectRoot ?? "" } \0${ candidate . realPath } ` ;
363+ const cached = projectMembership . get ( cacheKey ) ;
364+ if ( cached !== undefined ) {
365+ return cached ;
366+ }
367+
368+ const isWithinProject = isCandidateWithinProject ( candidate , projectRoot ) ;
369+ projectMembership . set ( cacheKey , isWithinProject ) ;
370+ return isWithinProject ;
371+ }
372+
243373function staticMatchReason ( rule : LoadedRule ) : MatchReason | null {
244374 if ( rule . frontmatter . alwaysApply === true ) {
245375 return "alwaysApply" ;
@@ -314,6 +444,10 @@ function toPosixPath(path: string): string {
314444 return path . replaceAll ( "\\" , "/" ) ;
315445}
316446
447+ function uniqueStrings ( values : ReadonlyArray < string > ) : string [ ] {
448+ return [ ...new Set ( values ) ] ;
449+ }
450+
317451function storeLastLoad (
318452 state : SessionState ,
319453 rules : ReadonlyArray < LoadedRule > ,
0 commit comments