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