@@ -19,6 +19,8 @@ package bzlmod
1919
2020import (
2121 "path"
22+ "strings"
23+ "unicode"
2224
2325 "github.com/bazelbuild/buildtools/build"
2426 "github.com/bazelbuild/buildtools/labels"
@@ -118,10 +120,20 @@ func NewUseRepo(f *build.File, proxies []string) (*build.File, *build.CallExpr)
118120 return & build.File {Path : f .Path , Comments : f .Comments , Stmt : stmt , Type : build .TypeModule }, useRepo
119121}
120122
123+ // repoToAdd represents a repository to be added to a use_repo call.
124+ type repoToAdd struct {
125+ key string
126+ value string
127+ isMapping bool
128+ isValidIdent bool
129+ }
130+
121131// AddRepoUsages adds the given repos to the given use_repo calls without introducing duplicate
122132// arguments.
123133// useRepos must not be empty.
124- // Keyword arguments are preserved but adding them is currently not supported.
134+ // Keyword arguments are preserved and can be added using the syntax "key=value".
135+ // For invalid identifiers (e.g., containing dots), dict unpacking syntax is used.
136+ // If a mapping conflicts with an existing one (same key or same value), the existing one is replaced.
125137func AddRepoUsages (useRepos []* build.CallExpr , repos ... string ) {
126138 if len (repos ) == 0 {
127139 return
@@ -130,25 +142,170 @@ func AddRepoUsages(useRepos []*build.CallExpr, repos ...string) {
130142 panic ("useRepos must not be empty" )
131143 }
132144
133- seen := make (map [string ]struct {})
145+ // Parse all repos to add and track which keys/values are being added
146+ var reposToAdd []repoToAdd
147+ mappingValuesToAdd := make (map [string ]struct {}) // Values being added as mappings
148+ keysToAdd := make (map [string ]struct {})
149+
150+ for _ , repo := range repos {
151+ key , value , isMapping := parseRepoMapping (repo )
152+ validIdent := isValidIdentifier (key )
153+
154+ reposToAdd = append (reposToAdd , repoToAdd {
155+ key : key ,
156+ value : value ,
157+ isMapping : isMapping ,
158+ isValidIdent : validIdent ,
159+ })
160+
161+ if isMapping {
162+ mappingValuesToAdd [value ] = struct {}{}
163+ keysToAdd [key ] = struct {}{}
164+ }
165+ }
166+
167+ // Track existing values to detect duplicates when adding positional args
168+ existingValues := collectExistingValues (useRepos )
169+
170+ // Remove conflicting arguments from all use_repo calls
171+ removeConflictingRepos (useRepos , mappingValuesToAdd , keysToAdd )
172+
173+ // Add new repos to the last use_repo call
174+ lastUseRepo := getLastUseRepo (useRepos )
175+ addNewRepos (lastUseRepo , reposToAdd , existingValues )
176+ }
177+
178+ // collectExistingValues returns a set of all repository values currently referenced in use_repo calls.
179+ func collectExistingValues (useRepos []* build.CallExpr ) map [string ]struct {} {
180+ existingValues := make (map [string ]struct {})
134181 for _ , useRepo := range useRepos {
135182 if len (useRepo .List ) == 0 {
136- // Invalid use_repo call, skip.
137183 continue
138184 }
139185 for _ , arg := range useRepo .List [1 :] {
140- seen [repoFromUseRepoArg (arg )] = struct {}{}
186+ if val := repoFromUseRepoArg (arg ); val != "" {
187+ existingValues [val ] = struct {}{}
188+ }
141189 }
142190 }
191+ return existingValues
192+ }
143193
144- lastUseRepo := getLastUseRepo (useRepos )
145- for _ , repo := range repos {
146- if _ , ok := seen [repo ]; ok {
194+ // removeConflictingRepos removes arguments from use_repo calls that conflict with repos being added.
195+ func removeConflictingRepos (useRepos []* build.CallExpr , mappingValuesToAdd , keysToAdd map [string ]struct {}) {
196+ for _ , useRepo := range useRepos {
197+ if len (useRepo .List ) == 0 {
147198 continue
148199 }
149- // Sorting of use_repo arguments is handled by Buildify.
150- // TODO: Add a keyword argument instead if repo is of the form "key=value".
151- lastUseRepo .List = append (lastUseRepo .List , & build.StringExpr {Value : repo })
200+
201+ var newArgs []build.Expr
202+ var dictToUpdate * build.DictExpr
203+
204+ for _ , arg := range useRepo .List [1 :] {
205+ shouldKeep := true
206+
207+ switch arg := arg .(type ) {
208+ case * build.StringExpr :
209+ // Positional argument: only remove if a mapping is being added with this value
210+ if _ , conflicts := mappingValuesToAdd [arg .Value ]; conflicts {
211+ shouldKeep = false
212+ }
213+ case * build.AssignExpr :
214+ // Keyword argument: remove if same key is being added
215+ if ident , ok := arg .LHS .(* build.Ident ); ok {
216+ if _ , conflicts := keysToAdd [ident .Name ]; conflicts {
217+ shouldKeep = false
218+ }
219+ }
220+ // Or if same value is being added as a mapping (replacement)
221+ if str , ok := arg .RHS .(* build.StringExpr ); ok {
222+ if _ , conflicts := mappingValuesToAdd [str .Value ]; conflicts {
223+ shouldKeep = false
224+ }
225+ }
226+ case * build.UnaryExpr :
227+ // Dict unpacking: we'll handle this separately
228+ if arg .Op == "**" {
229+ if dict , ok := arg .X .(* build.DictExpr ); ok {
230+ dictToUpdate = dict
231+ shouldKeep = false // Remove from list, we'll re-add after filtering
232+ }
233+ }
234+ }
235+
236+ if shouldKeep {
237+ newArgs = append (newArgs , arg )
238+ }
239+ }
240+
241+ // Filter dict entries if we have a dict to update
242+ if dictToUpdate != nil {
243+ filteredDictEntries := filterDictEntries (dictToUpdate , mappingValuesToAdd , keysToAdd )
244+ // Only keep dict if it still has entries after filtering
245+ if len (filteredDictEntries ) > 0 {
246+ dictToUpdate .List = filteredDictEntries
247+ newArgs = append (newArgs , & build.UnaryExpr {Op : "**" , X : dictToUpdate })
248+ }
249+ }
250+
251+ useRepo .List = append (useRepo .List [:1 ], newArgs ... )
252+ }
253+ }
254+
255+ // filterDictEntries removes dict entries that conflict with repos being added.
256+ func filterDictEntries (dict * build.DictExpr , mappingValuesToAdd , keysToAdd map [string ]struct {}) []* build.KeyValueExpr {
257+ var filteredDictEntries []* build.KeyValueExpr
258+ for _ , kv := range dict .List {
259+ shouldKeep := true
260+ // Check if key conflicts
261+ if keyStr , ok := kv .Key .(* build.StringExpr ); ok {
262+ if _ , conflicts := keysToAdd [keyStr .Value ]; conflicts {
263+ shouldKeep = false
264+ }
265+ }
266+ // Check if value conflicts with a mapping being added
267+ if valStr , ok := kv .Value .(* build.StringExpr ); ok {
268+ if _ , conflicts := mappingValuesToAdd [valStr .Value ]; conflicts {
269+ shouldKeep = false
270+ }
271+ }
272+ if shouldKeep {
273+ filteredDictEntries = append (filteredDictEntries , kv )
274+ }
275+ }
276+ return filteredDictEntries
277+ }
278+
279+ // addNewRepos adds new repository arguments to the given use_repo call.
280+ func addNewRepos (useRepo * build.CallExpr , reposToAdd []repoToAdd , existingValues map [string ]struct {}) {
281+ var dictEntries []* build.KeyValueExpr
282+
283+ for _ , repo := range reposToAdd {
284+ if ! repo .isMapping {
285+ // Simple positional argument: use_repo(ext, "foo")
286+ // Skip if already exists (to preserve existing mappings)
287+ if _ , exists := existingValues [repo .value ]; ! exists {
288+ useRepo .List = append (useRepo .List , & build.StringExpr {Value : repo .value })
289+ }
290+ } else if repo .isValidIdent {
291+ // Valid identifier as keyword argument: use_repo(ext, bar = "baz")
292+ useRepo .List = append (useRepo .List , & build.AssignExpr {
293+ LHS : & build.Ident {Name : repo .key },
294+ Op : "=" ,
295+ RHS : & build.StringExpr {Value : repo .value },
296+ })
297+ } else {
298+ // Invalid identifier, need dict unpacking: use_repo(ext, **{"foo.2": "foo"})
299+ dictEntries = append (dictEntries , & build.KeyValueExpr {
300+ Key : & build.StringExpr {Value : repo .key },
301+ Value : & build.StringExpr {Value : repo .value },
302+ })
303+ }
304+ }
305+
306+ // If we have dict entries, add or extend the **kwargs dict
307+ if len (dictEntries ) > 0 {
308+ addOrExtendDictUnpack (useRepo , dictEntries )
152309 }
153310}
154311
@@ -432,3 +589,60 @@ func collectApparentNamesAndIncludes(f *build.File) (map[string]string, []string
432589
433590 return apparentNames , includeLabels
434591}
592+
593+ // parseRepoMapping parses a repo string which may be in the form "key=value" or just "value".
594+ // Returns (key, value, isMapping) where isMapping indicates if it was a mapping.
595+ func parseRepoMapping (repo string ) (key , value string , isMapping bool ) {
596+ parts := strings .SplitN (repo , "=" , 2 )
597+ if len (parts ) == 2 {
598+ return parts [0 ], parts [1 ], true
599+ }
600+ return "" , repo , false
601+ }
602+
603+ // isValidIdentifier checks if a string is a valid Python identifier.
604+ // Per Python rules: must start with letter or underscore, followed by letters, digits, or underscores.
605+ func isValidIdentifier (s string ) bool {
606+ if len (s ) == 0 {
607+ return false
608+ }
609+
610+ for i , r := range s {
611+ if i == 0 {
612+ // First character must be letter or underscore
613+ if ! unicode .IsLetter (r ) && r != '_' {
614+ return false
615+ }
616+ } else {
617+ // Subsequent characters must be letter, digit, or underscore
618+ if ! unicode .IsLetter (r ) && ! unicode .IsDigit (r ) && r != '_' {
619+ return false
620+ }
621+ }
622+ }
623+
624+ return true
625+ }
626+
627+ // addOrExtendDictUnpack adds dict entries to an existing **kwargs dict unpacking expression,
628+ // or creates a new one if none exists.
629+ func addOrExtendDictUnpack (useRepo * build.CallExpr , newEntries []* build.KeyValueExpr ) {
630+ // Look for an existing **dict unpacking expression
631+ for _ , arg := range useRepo .List {
632+ if unary , ok := arg .(* build.UnaryExpr ); ok && unary .Op == "**" {
633+ if dict , ok := unary .X .(* build.DictExpr ); ok {
634+ // Found existing dict unpacking, add new entries to it
635+ dict .List = append (dict .List , newEntries ... )
636+ return
637+ }
638+ }
639+ }
640+
641+ // No existing **dict found, create a new one
642+ useRepo .List = append (useRepo .List , & build.UnaryExpr {
643+ Op : "**" ,
644+ X : & build.DictExpr {
645+ List : newEntries ,
646+ },
647+ })
648+ }
0 commit comments