@@ -35,6 +35,7 @@ import {
3535 plainItem ,
3636 ProcessGroup ,
3737 ProgramConfig ,
38+ ProgramOrigin ,
3839 ProgramRecord ,
3940} from "../config/schema" ;
4041import { isBoolean , isNumber , isStringArray } from "../utils/types" ;
@@ -65,6 +66,7 @@ export {
6566 type ProcessGroup ,
6667 type ProgramConfig ,
6768 type ProgramEntry ,
69+ type ProgramOrigin ,
6870 type ProgramRecord ,
6971} from "../config/schema" ;
7072
@@ -186,6 +188,26 @@ interface ConfigSource {
186188 document : TextDocument ;
187189}
188190
191+ /**
192+ * Precedence of a program-config match against a file, **lowest value wins**.
193+ * Used by {@link PluginConfigurationProvider.getProgramConfig} to pick the
194+ * best match rather than the first hit, so a user-scope `settings.json` entry
195+ * can never shadow a project `.pliplugin/` entry that also matches:
196+ *
197+ * 1. source — project (`.pliplugin/`) beats settings;
198+ * 2. specificity — an exact path beats a glob (within the same source).
199+ *
200+ * Ties beyond this are broken by insertion order (project entries are merged
201+ * first). `ProjectExact` is the best possible rank, so matching it lets the
202+ * lookup stop early.
203+ */
204+ const ProgramMatchRank = {
205+ ProjectExact : 0 ,
206+ ProjectGlob : 1 ,
207+ SettingsExact : 2 ,
208+ SettingsGlob : 3 ,
209+ } as const ;
210+
189211/**
190212 * Plugin configuration provider for loading '.pliplugin/pgm_conf.json' and '.pliplugin/proc_grps.json' (when they exist),
191213 * processing their contents, and making those settings available to the language server.
@@ -473,23 +495,44 @@ export class PluginConfigurationProvider {
473495 ( source ) : source is ConfigSource => source !== undefined ,
474496 ) ;
475497
476- // Parse pgm_conf sources and merge by resolved program URI.
477- const mergedPrograms = new Map < string , ProgramConfig > ( ) ;
478- for ( const source of pgmSources ) {
498+ // Parse pgm_conf sources and merge by resolved program URI, tagging each
499+ // entry with its origin (project `.pliplugin/` vs. user-scope settings).
500+ //
501+ // Two layers of precedence work together:
502+ // 1. Here, `.pliplugin/` sources are processed FIRST (the reverse of
503+ // `pgmSources`) and the `has` guard keeps the first entry per key, so a
504+ // project entry wins any exact-key collision with settings.
505+ // 2. `getProgramConfig` then prefers project entries over settings entries
506+ // at lookup time (see its ranking). Together these guarantee a settings
507+ // program entry can never shadow a project entry that also matches the
508+ // file — whether the settings entry is a broad glob (e.g. `**/*`) or an
509+ // exact path (e.g. `a.pli`, which a plain `Map.get` would otherwise let
510+ // win via a direct key hit).
511+ const projectPgmConfUri = plipluginPgmConfUri . toString ( ) ;
512+ const mergedPrograms = new Map <
513+ string ,
514+ { config : ProgramConfig ; origin : ProgramOrigin }
515+ > ( ) ;
516+ for ( const source of [ ...pgmSources ] . reverse ( ) ) {
479517 this . knownPgmConfUris . add ( source . uri . toString ( ) ) ;
480518 const result = parseProgramConfigs ( source . text , source . uri , source . entry ) ;
481519 diagnostics . push ( ...result . diagnostics ) ;
482520 if ( result . config ) {
521+ const origin : ProgramOrigin =
522+ source . uri . toString ( ) === projectPgmConfUri ? "project" : "settings" ;
483523 for ( const config of result . config ) {
484524 const resolvedUri = this . resolveProgramPath (
485525 config . program . value ,
486526 this . workspacePath ,
487527 ) ;
488- mergedPrograms . set ( resolvedUri . toString ( ) , config ) ;
528+ const key = resolvedUri . toString ( ) ;
529+ if ( ! mergedPrograms . has ( key ) ) {
530+ mergedPrograms . set ( key , { config, origin } ) ;
531+ }
489532 }
490533 }
491534 }
492- this . setProgramConfigs (
535+ this . applyProgramConfigs (
493536 this . workspacePath ,
494537 Array . from ( mergedPrograms . values ( ) ) ,
495538 ) ;
@@ -866,10 +909,28 @@ export class PluginConfigurationProvider {
866909 public setProgramConfigs (
867910 workspaceUri : URI ,
868911 programConfigs : ProgramConfig [ ] ,
912+ ) : void {
913+ // Direct callers (quick-fix add, `.pliplugin/`-only parse, tests) are all
914+ // project-scoped, so their entries bind at project precedence.
915+ this . applyProgramConfigs (
916+ workspaceUri ,
917+ programConfigs . map ( ( config ) => ( { config, origin : "project" as const } ) ) ,
918+ ) ;
919+ }
920+
921+ /**
922+ * Rebuilds the program-config map from the given entries, each tagged with
923+ * the source it came from ({@link ProgramOrigin}). Program paths are
924+ * normalized and resolved relative to the workspace (unless absolute), then
925+ * post-processed so abstract options are built.
926+ */
927+ private applyProgramConfigs (
928+ workspaceUri : URI ,
929+ entries : Array < { config : ProgramConfig ; origin : ProgramOrigin } > ,
869930 ) : void {
870931 this . programConfigs . clear ( ) ;
871932
872- for ( const config of programConfigs ) {
933+ for ( const { config, origin } of entries ) {
873934 const resolvedUri = this . resolveProgramPath (
874935 config . program . value ,
875936 workspaceUri ,
@@ -881,6 +942,7 @@ export class PluginConfigurationProvider {
881942 ...config ,
882943 abstractOptions : { options : [ ] , tokens : [ ] , issues : [ ] , comments : [ ] } ,
883944 issues : [ ] ,
945+ origin,
884946 } ) ;
885947 }
886948 this . postProcessProgramConfigs ( ) ;
@@ -996,11 +1058,14 @@ export class PluginConfigurationProvider {
9961058
9971059 /**
9981060 * Returns the program config for the given program URI.
999- * Lookup order:
1000- * 1. Exact match against registered config keys.
1001- * 2. Glob pattern match (using minimatch) against decoded URIs.
10021061 *
1003- * @see https://github.com/isaacs/minimatch
1062+ * Rather than returning the first hit, this picks the *highest-precedence*
1063+ * match (see {@link ProgramMatchRank}) so a user-scope `settings.json` entry
1064+ * can never shadow a project `.pliplugin/` entry that also matches. This is
1065+ * what fixes the exact-path settings case: a plain `Map.get(uri)` direct hit
1066+ * would return a settings `program: "a.pli"` entry even when a project glob
1067+ * (e.g. `*.pli`) also matches; ranking lets the project glob win instead.
1068+ *
10041069 * @param program Name of the program to get a config for
10051070 * @returns Associated program config, or undefined if not found
10061071 */
@@ -1010,32 +1075,60 @@ export class PluginConfigurationProvider {
10101075 // the path alone is sufficient.
10111076 // Note that we need to decode the URI
10121077 const uri = program . toString ( true ) ;
1013- const direct = this . programConfigs . get ( uri ) ;
1014- if ( direct ) {
1015- return direct ;
1016- }
1017- // fallback to glob matching
1078+
1079+ let best : ProgramRecord | undefined ;
1080+ let bestRank = Number . POSITIVE_INFINITY ;
10181081 for ( const [ pattern , config ] of this . programConfigs . entries ( ) ) {
1019- if ( pattern === uri ) {
1020- continue ; // already checked
1082+ const rank = this . programMatchRank ( uri , pattern , config ) ;
1083+ if ( rank === undefined || rank >= bestRank ) {
1084+ continue ; // no match, or not better than what we already have
10211085 }
1086+ best = config ;
1087+ bestRank = rank ;
1088+ if ( rank === ProgramMatchRank . ProjectExact ) {
1089+ break ; // best possible rank
1090+ }
1091+ }
1092+ return best ;
1093+ }
1094+
1095+ /**
1096+ * Precedence of a single program-config entry as a match for `uri`, or
1097+ * `undefined` when its pattern does not match the file. Lower ranks win;
1098+ * see {@link ProgramMatchRank}.
1099+ *
1100+ * @param uri Decoded program URI being resolved.
1101+ * @param pattern The program config's key (an exact path or a glob).
1102+ * @param record The candidate program config.
1103+ */
1104+ private programMatchRank (
1105+ uri : string ,
1106+ pattern : string ,
1107+ record : ProgramRecord ,
1108+ ) : number | undefined {
1109+ const isExact = pattern === uri ;
1110+ if ( ! isExact ) {
10221111 try {
10231112 // attempt match on decoded URI
1024- if (
1025- minimatch ( uri , decodeURIComponent ( pattern ) , {
1026- nocase : true ,
1027- } )
1028- ) {
1029- return config ;
1113+ if ( ! minimatch ( uri , decodeURIComponent ( pattern ) , { nocase : true } ) ) {
1114+ return undefined ;
10301115 }
10311116 } catch ( e ) {
10321117 console . error (
1033- `Invalid glob pattern "${ pattern } " for program "${ program } ": ${ e } ` ,
1118+ `Invalid glob pattern "${ pattern } " for program "${ uri } ": ${ e } ` ,
10341119 ) ;
1120+ return undefined ;
10351121 }
10361122 }
1037- // no match
1038- return undefined ;
1123+ // Absent origin is treated as "project" (see ProgramRecord.origin).
1124+ if ( record . origin === "settings" ) {
1125+ return isExact
1126+ ? ProgramMatchRank . SettingsExact
1127+ : ProgramMatchRank . SettingsGlob ;
1128+ }
1129+ return isExact
1130+ ? ProgramMatchRank . ProjectExact
1131+ : ProgramMatchRank . ProjectGlob ;
10391132 }
10401133
10411134 /**
0 commit comments