github.com/bazelbuild/bazel-gazelle@v0.36.1-0.20240520142334-61b277ba6fed/internal/bzlmod/go_mod.bzl (about) 1 # Copyright 2023 The Bazel Authors. All rights reserved. 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # http://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 15 load(":semver.bzl", "COMPARES_HIGHEST_SENTINEL") 16 17 visibility([ 18 "//tests/bzlmod/...", 19 ]) 20 21 def _validate_go_version(path, state, tokens, line_no): 22 if len(tokens) == 1: 23 fail("{}:{}: expected another token after 'go'".format(path, line_no)) 24 if state["go"] != None: 25 fail("{}:{}: unexpected second 'go' directive".format(path, line_no)) 26 if len(tokens) > 2: 27 fail("{}:{}: unexpected token '{}' after '{}'".format(path, line_no, tokens[2], tokens[1])) 28 29 def use_spec_to_label(repo_name, use_directive): 30 if use_directive.startswith("../") or "/../" in use_directive or use_directive.endswith("/.."): 31 fail("go.work use directive: '{}' contains '..' which is not currently supported.".format(use_directive)) 32 33 if use_directive.startswith("/"): 34 fail("go.work use directive: '{}' is an absolute path, which is not currently supported.".format(use_directive)) 35 36 if use_directive.startswith("./"): 37 use_directive = use_directive[2:] 38 39 if use_directive.endswith("/"): 40 use_directive = use_directive[:-1] 41 42 if use_directive == ".": 43 use_directive = "" 44 45 return Label("@@{}//{}:go.mod".format(repo_name, use_directive)) 46 47 def go_work_from_label(module_ctx, go_work_label): 48 """Loads deps from a go.work file""" 49 go_work_path = module_ctx.path(go_work_label) 50 go_work_content = module_ctx.read(go_work_path) 51 go_work = parse_go_work(go_work_content, go_work_label) 52 53 return _relativize_replace_paths(go_work, go_work_path) 54 55 def parse_go_work(content, go_work_label): 56 # see: https://go.dev/ref/mod#go-work-file 57 58 # Valid directive values understood by this parser never contain tabs or 59 # carriage returns, so we can simplify the parsing below by canonicalizing 60 # whitespace upfront. 61 content = content.replace("\t", " ").replace("\r", " ") 62 63 state = { 64 "go": None, 65 "use": [], 66 "replace": {}, 67 } 68 69 current_directive = None 70 for line_no, line in enumerate(content.splitlines(), 1): 71 tokens, _ = _tokenize_line(line, go_work_label.name, line_no) 72 73 if not tokens: 74 continue 75 76 if current_directive: 77 if tokens[0] == ")": 78 current_directive = None 79 elif current_directive == "use": 80 state["use"].append(tokens[0]) 81 elif current_directive == "replace": 82 _parse_replace_directive(state, tokens, go_work_label.name, line_no) 83 else: 84 fail("{}:{}: unexpected directive '{}'".format(go_work_label.name, line_no, current_directive)) 85 elif tokens[0] == "go": 86 _validate_go_version(go_work_label.name, state, tokens, line_no) 87 go = tokens[1] 88 elif tokens[0] == "replace": 89 if tokens[1] == "(": 90 current_directive = tokens[0] 91 continue 92 else: 93 _parse_replace_directive(state, tokens[1:], go_work_label.name, line_no) 94 elif tokens[0] == "use": 95 if len(tokens) != 2: 96 fail("{}:{}: expected path or block in 'use' directive".format(go_work_label.name, line_no)) 97 elif tokens[1] == "(": 98 current_directive = tokens[0] 99 continue 100 else: 101 state["use"].append(tokens[1]) 102 else: 103 fail("{}:{}: unexpected directive '{}'".format(go_work_label.name, line_no, tokens[0])) 104 105 major, minor = go.split(".")[:2] 106 107 go_mods = [use_spec_to_label(go_work_label.workspace_name, use) for use in state["use"]] 108 from_file_tags = [struct(go_mod = go_mod, _is_dev_dependency = False) for go_mod in go_mods] 109 110 module_tags = [struct(version = mod.version, path = mod.to_path, _parent_label = go_work_label, local_path = mod.local_path, indirect = False) for mod in state["replace"].values()] 111 112 return struct( 113 go = (int(major), int(minor)), 114 from_file_tags = from_file_tags, 115 replace_map = state["replace"], 116 module_tags = module_tags, 117 use = state["use"], 118 ) 119 120 # this exists because we are unable to create a path object in unit tests, we 121 # must do this as a post-process step or we cannot unit test go_mod parsing 122 def _relativize_replace_paths(go_mod, go_mod_path): 123 new_replace_map = {} 124 125 for key in go_mod.replace_map: 126 value = go_mod.replace_map[key] 127 128 local_path = value.local_path 129 130 if local_path: 131 # drop the go.mod from the path, to get the directory 132 directory = go_mod_path.dirname 133 134 # now that we have the directory, we can use the use replace directive to get the full path 135 local_path = str(directory.get_child(local_path)) 136 137 new_replace_map[key] = struct( 138 from_version = value.from_version, 139 to_path = value.to_path, 140 version = value.version, 141 local_path = local_path, 142 ) 143 144 new_go_mod = { 145 attr: getattr(go_mod, attr) 146 for attr in dir(go_mod) 147 if not type(getattr(go_mod, attr)) == "builtin_function_or_method" 148 } 149 150 new_go_mod["replace_map"] = new_replace_map 151 return struct(**new_go_mod) 152 153 def deps_from_go_mod(module_ctx, go_mod_label): 154 """Loads the entries from a go.mod file. 155 156 Args: 157 module_ctx: a https://bazel.build/rules/lib/module_ctx object passed 158 from the MODULE.bazel call. 159 go_mod_label: a Label for a `go.mod` file. 160 161 Returns: 162 a tuple (Go module path, deps, replace map), where deps is a list of structs representing 163 `require` statements from the go.mod file. 164 """ 165 _check_go_mod_name(go_mod_label.name) 166 167 go_mod_path = module_ctx.path(go_mod_label) 168 go_mod_content = module_ctx.read(go_mod_path) 169 go_mod = parse_go_mod(go_mod_content, go_mod_path) 170 go_mod = _relativize_replace_paths(go_mod, go_mod_path) 171 172 if go_mod.go[0] != 1 or go_mod.go[1] < 17: 173 # go.mod files only include entries for all transitive dependencies as 174 # of Go 1.17. 175 fail("go_deps.from_file requires a go.mod file generated by Go 1.17 or later. Fix {} with 'go mod tidy -go=1.17'.".format(go_mod_label)) 176 177 deps = [] 178 for require in go_mod.require: 179 deps.append(struct( 180 path = require.path, 181 version = require.version, 182 indirect = require.indirect, 183 local_path = None, 184 _parent_label = go_mod_label, 185 )) 186 187 return go_mod.module, deps, go_mod.replace_map, go_mod.module 188 189 def parse_go_mod(content, path): 190 # See https://go.dev/ref/mod#go-mod-file. 191 192 # Valid directive values understood by this parser never contain tabs or 193 # carriage returns, so we can simplify the parsing below by canonicalizing 194 # whitespace upfront. 195 content = content.replace("\t", " ").replace("\r", " ") 196 197 state = { 198 "module": None, 199 "go": None, 200 "require": [], 201 "replace": {}, 202 } 203 204 current_directive = None 205 for line_no, line in enumerate(content.splitlines(), 1): 206 tokens, comment = _tokenize_line(line, path, line_no) 207 if not tokens: 208 continue 209 210 if not current_directive: 211 if tokens[0] not in ["module", "go", "require", "replace", "exclude", "retract", "toolchain"]: 212 fail("{}:{}: unexpected token '{}' at start of line".format(path, line_no, tokens[0])) 213 if len(tokens) == 1: 214 fail("{}:{}: expected another token after '{}'".format(path, line_no, tokens[0])) 215 216 # The 'go' directive only has a single-line form and is thus parsed 217 # here rather than in _parse_directive. 218 if tokens[0] == "go": 219 _validate_go_version(path, state, tokens, line_no) 220 state["go"] = tokens[1] 221 222 if tokens[1] == "(": 223 current_directive = tokens[0] 224 if len(tokens) > 2: 225 fail("{}:{}: unexpected token '{}' after '('".format(path, line_no, tokens[2])) 226 continue 227 228 _parse_directive(state, tokens[0], tokens[1:], comment, path, line_no) 229 230 elif tokens[0] == ")": 231 current_directive = None 232 if len(tokens) > 1: 233 fail("{}:{}: unexpected token '{}' after ')'".format(path, line_no, tokens[1])) 234 continue 235 236 else: 237 _parse_directive(state, current_directive, tokens, comment, path, line_no) 238 239 module = state["module"] 240 if not module: 241 fail("Expected a module directive in go.mod file") 242 243 go = state["go"] 244 if not go: 245 # "As of the Go 1.17 release, if the go directive is missing, go 1.16 is assumed." 246 go = "1.16" 247 248 # The go directive can contain patch and pre-release versions, but we omit them. 249 major, minor = go.split(".")[:2] 250 251 return struct( 252 module = module, 253 go = (int(major), int(minor)), 254 require = tuple(state["require"]), 255 replace_map = state["replace"], 256 ) 257 258 def _parse_directive(state, directive, tokens, comment, path, line_no): 259 if directive == "module": 260 if state["module"] != None: 261 fail("{}:{}: unexpected second 'module' directive".format(path, line_no)) 262 if len(tokens) > 1: 263 fail("{}:{}: unexpected token '{}' after '{}'".format(path, line_no, tokens[1])) 264 state["module"] = tokens[0] 265 elif directive == "require": 266 if len(tokens) != 2: 267 fail("{}:{}: expected module path and version in 'require' directive".format(path, line_no)) 268 state["require"].append(struct( 269 path = tokens[0], 270 version = tokens[1], 271 indirect = comment == "indirect", 272 )) 273 elif directive == "replace": 274 _parse_replace_directive(state, tokens, path, line_no) 275 276 # TODO: Handle exclude. 277 278 def _parse_replace_directive(state, tokens, path, line_no): 279 # replacements key off of the from_path 280 from_path = tokens[0] 281 282 # pattern: replace from_path => to_path to_version 283 if len(tokens) == 4 and tokens[1] == "=>": 284 state["replace"][from_path] = struct( 285 from_version = None, 286 to_path = tokens[2], 287 local_path = None, 288 version = _canonicalize_raw_version(tokens[3]), 289 ) 290 291 # pattern: replace from_path from_version => to_path to_version 292 elif len(tokens) == 5 and tokens[2] == "=>": 293 state["replace"][from_path] = struct( 294 from_version = _canonicalize_raw_version(tokens[1]), 295 to_path = tokens[3], 296 version = _canonicalize_raw_version(tokens[4]), 297 local_path = None, 298 ) 299 300 # pattern: replace from_path from_version => file_path 301 elif len(tokens) == 4 and tokens[2] == "=>": 302 state["replace"][from_path] = struct( 303 from_version = _canonicalize_raw_version(tokens[1]), 304 to_path = from_path, 305 local_path = tokens[3], 306 version = COMPARES_HIGHEST_SENTINEL, 307 ) 308 309 # pattern: replace from_path => to_path 310 elif len(tokens) == 3 and tokens[1] == "=>": 311 state["replace"][from_path] = struct( 312 from_version = None, 313 to_path = from_path, 314 local_path = tokens[2], 315 version = COMPARES_HIGHEST_SENTINEL, 316 ) 317 else: 318 fail("{}:{}: unexpected tokens '{}'".format(path, line_no, tokens)) 319 320 def _tokenize_line(line, path, line_no): 321 tokens = [] 322 r = line 323 for _ in range(len(line)): 324 r = r.strip() 325 if not r: 326 break 327 328 if r[0] == "`": 329 end = r.find("`", 1) 330 if end == -1: 331 fail("{}:{}: unterminated raw string".format(path, line_no)) 332 333 tokens.append(r[1:end]) 334 r = r[end + 1:] 335 336 elif r[0] == "\"": 337 value = "" 338 escaped = False 339 found_end = False 340 for pos in range(1, len(r)): 341 c = r[pos] 342 343 if escaped: 344 value += c 345 escaped = False 346 continue 347 348 if c == "\\": 349 escaped = True 350 continue 351 352 if c == "\"": 353 found_end = True 354 break 355 356 value += c 357 358 if not found_end: 359 fail("{}:{}: unterminated interpreted string".format(path, line_no)) 360 361 tokens.append(value) 362 r = r[pos + 1:] 363 364 elif r.startswith("//"): 365 # A comment always ends the current line 366 return tokens, r[len("//"):].strip() 367 368 else: 369 token, _, r = r.partition(" ") 370 tokens.append(token) 371 372 return tokens, None 373 374 def sums_from_go_mod(module_ctx, go_mod_label): 375 """Loads the entries from a go.sum file given a go.mod Label. 376 377 Args: 378 module_ctx: a https://bazel.build/rules/lib/module_ctx object 379 passed from the MODULE.bazel call. 380 go_mod_label: a Label for a `go.mod` file. This label is used 381 to find the associated `go.sum` file. 382 383 Returns: 384 A Dict[(string, string) -> (string)] is retruned where each entry 385 is defined by a Go Module's sum: 386 (path, version) -> (sum) 387 """ 388 _check_go_mod_name(go_mod_label.name) 389 390 return parse_sumfile(module_ctx, go_mod_label, "go.sum") 391 392 def sums_from_go_work(module_ctx, go_work_label): 393 """Loads the entries from a go.work.sum file given a go.work label. 394 395 Args: 396 module_ctx: a https://bazel.build/rules/lib/module_ctx object 397 passed from the MODULE.bazel call. 398 go_work_label: a Label for a `go.work` file. This label is used 399 to find the associated `go.work.sum` file. 400 401 Returns: 402 A Dict[(string, string) -> (string)] is returned where each entry 403 is defined by a Go Module's sum: 404 (path, version) -> (sum) 405 """ 406 _check_go_work_name(go_work_label.name) 407 408 # next we need to test if the go.work.sum file exists, this is a little tricky so we use an indirect approach: 409 410 # 1. convert go_work_label into a path 411 go_work_path = module_ctx.path(go_work_label) 412 413 # 2. use the go_work_path to create a path for the heisen go.work.sum file 414 maybe_go_work_sum_path = go_work_path.dirname.get_child("go.work.sum") 415 416 # 3. check for its existence 417 if maybe_go_work_sum_path.exists: 418 return parse_sumfile(module_ctx, go_work_label, "go.work.sum") 419 else: 420 # 4. if go.work.sum does not exist, we should watch it in case it appears in the future 421 if hasattr(module_ctx, "watch"): 422 # module_ctx.watch_tree is only available in bazel >= 7.1 423 module_ctx.watch(maybe_go_work_sum_path) 424 425 # 5. return an empty dict as no sum file was found 426 return {} 427 428 def parse_sumfile(module_ctx, label, sumfile): 429 # We go through a Label so that the module extension is restarted if the sumfile 430 # changes. We have to use a canonical label as we may not have visibility 431 # into the module that provides the sumfile 432 sum_label = Label("@@{}//{}:{}".format( 433 label.workspace_name, 434 label.package, 435 sumfile, 436 )) 437 438 return parse_go_sum(module_ctx.read(sum_label)) 439 440 def parse_go_sum(content): 441 hashes = {} 442 for line in content.splitlines(): 443 path, version, sum = line.split(" ") 444 version = _canonicalize_raw_version(version) 445 if not version.endswith("/go.mod"): 446 hashes[(path, version)] = sum 447 return hashes 448 449 def _check_go_mod_name(name): 450 if name != "go.mod": 451 fail("go_deps.from_file requires a 'go.mod' file, not '{}'".format(name)) 452 453 def _check_go_work_name(name): 454 if name != "go.work": 455 fail("go_deps.from_file requires a 'go.work' file, not '{}'".format(name)) 456 457 def _canonicalize_raw_version(raw_version): 458 if raw_version.startswith("v"): 459 return raw_version[1:] 460 return raw_version