Repository: spack/spack

Issue:
Bug: GitVersion with assigned ref not parsing with variants without space
`spec::__str__` places variants next to versions with no spaces.  This is not parsing with the `GitVersions` when the references is assigned (`foo@git.[hash]=develop+var1`).  The error shows up when spack tries to read a `spack.lock` and is unable to parse the root specs.

An example from a lock file
```xml
  "roots": [
    {
      "hash": "pqiebnzqdv7tpuwepniftbdwxoplokag",
      "spec": "exawind@git.a7d3b373957a3ac391c94eeb4ea99a9062140eed=master+hypre+openfast arch=linux-None-x86_64"
    },
```

So there are two potential fixes: 

1.  Make it so there is always  a space between the version and the variants
2. Make the spec parser able to distinguish this as a valid spec.

I've been pursuing path 2, which seems better, but more complicated.  Not sure I understand why the parser is hanging onto the these variants when it creates the version.

There is a second bug in that `GitVersions` fetch calls are not cloning submodules recursively.  I want to fix both, but will open a second PR for this issue if it is not a simple fix.

@becker33 


Discussion:

Resolving patch:
diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py
--- a/lib/spack/spack/spec.py
+++ b/lib/spack/spack/spec.py
@@ -4981,7 +4981,7 @@ def __missing__(self, key):
 
 
 #: These are possible token types in the spec grammar.
-HASH, DEP, AT, COLON, COMMA, ON, OFF, PCT, EQ, ID, VAL, FILE = range(12)
+HASH, DEP, VER, COLON, COMMA, ON, OFF, PCT, EQ, ID, VAL, FILE = range(12)
 
 #: Regex for fully qualified spec names. (e.g., builtin.hdf5)
 spec_id_re = r"\w[\w.-]*"
@@ -5001,10 +5001,13 @@ def __init__(self):
         )
         super(SpecLexer, self).__init__(
             [
-                (r"\^", lambda scanner, val: self.token(DEP, val)),
-                (r"\@", lambda scanner, val: self.token(AT, val)),
+                (
+                    r"\@([\w.\-]*\s*)*(\s*\=\s*\w[\w.\-]*)?",
+                    lambda scanner, val: self.token(VER, val),
+                ),
                 (r"\:", lambda scanner, val: self.token(COLON, val)),
                 (r"\,", lambda scanner, val: self.token(COMMA, val)),
+                (r"\^", lambda scanner, val: self.token(DEP, val)),
                 (r"\+", lambda scanner, val: self.token(ON, val)),
                 (r"\-", lambda scanner, val: self.token(OFF, val)),
                 (r"\~", lambda scanner, val: self.token(OFF, val)),
@@ -5142,7 +5145,7 @@ def do_parse(self):
                 else:
                     # If the next token can be part of a valid anonymous spec,
                     # create the anonymous spec
-                    if self.next.type in (AT, ON, OFF, PCT):
+                    if self.next.type in (VER, ON, OFF, PCT):
                         # Raise an error if the previous spec is already concrete
                         if specs and specs[-1].concrete:
                             raise RedundantSpecError(specs[-1], "compiler, version, " "or variant")
@@ -5250,7 +5253,7 @@ def spec(self, name):
         spec.name = spec_name
 
         while self.next:
-            if self.accept(AT):
+            if self.accept(VER):
                 vlist = self.version_list()
                 spec._add_versions(vlist)
 
@@ -5268,7 +5271,6 @@ def spec(self, name):
             elif self.accept(ID):
                 self.previous = self.token
                 if self.accept(EQ):
-                    # We're adding a key-value pair to the spec
                     self.expect(VAL)
                     spec._add_flag(self.previous.value, self.token.value)
                     self.previous = None
@@ -5304,16 +5306,24 @@ def variant(self, name=None):
             return self.token.value
 
     def version(self):
+
         start = None
         end = None
-        if self.accept(ID):
-            start = self.token.value
-            if self.accept(EQ):
-                # This is for versions that are associated with a hash
-                # i.e. @[40 char hash]=version
-                start += self.token.value
-                self.expect(VAL)
-                start += self.token.value
+
+        def str_translate(value):
+            # return None for empty strings since we can end up with `'@'.strip('@')`
+            if not (value and value.strip()):
+                return None
+            else:
+                return value
+
+        if self.token.type is COMMA:
+            # need to increment commas, could be ID or COLON
+            self.accept(ID)
+
+        if self.token.type in (VER, ID):
+            version_spec = self.token.value.lstrip("@")
+            start = str_translate(version_spec)
 
         if self.accept(COLON):
             if self.accept(ID):
@@ -5323,10 +5333,10 @@ def version(self):
                 else:
                     end = self.token.value
         elif start:
-            # No colon, but there was a version.
+            # No colon, but there was a version
             return vn.Version(start)
         else:
-            # No colon and no id: invalid version.
+            # No colon and no id: invalid version
             self.next_token_error("Invalid version specifier")
 
         if start:
@@ -5349,7 +5359,7 @@ def compiler(self):
         compiler = CompilerSpec.__new__(CompilerSpec)
         compiler.name = self.token.value
         compiler.versions = vn.VersionList()
-        if self.accept(AT):
+        if self.accept(VER):
             vlist = self.version_list()
             compiler._add_versions(vlist)
         else:


Test patch:
diff --git a/lib/spack/spack/test/spec_syntax.py b/lib/spack/spack/test/spec_syntax.py
--- a/lib/spack/spack/test/spec_syntax.py
+++ b/lib/spack/spack/test/spec_syntax.py
@@ -31,63 +31,97 @@
 )
 from spack.variant import DuplicateVariantError
 
-# Sample output for a complex lexing.
-complex_lex = [
+# Building blocks for complex lexing.
+complex_root = [
     Token(sp.ID, "mvapich_foo"),
-    Token(sp.DEP),
-    Token(sp.ID, "_openmpi"),
-    Token(sp.AT),
-    Token(sp.ID, "1.2"),
-    Token(sp.COLON),
-    Token(sp.ID, "1.4"),
-    Token(sp.COMMA),
-    Token(sp.ID, "1.6"),
+]
+
+kv_root = [
+    Token(sp.ID, "mvapich_foo"),
+    Token(sp.ID, "debug"),
+    Token(sp.EQ),
+    Token(sp.VAL, "4"),
+]
+
+complex_compiler = [
     Token(sp.PCT),
     Token(sp.ID, "intel"),
-    Token(sp.AT),
+]
+
+complex_compiler_v = [
+    Token(sp.VER, "@12.1"),
+    Token(sp.COLON),
+    Token(sp.ID, "12.6"),
+]
+
+complex_compiler_v_space = [
+    Token(sp.VER, "@"),
     Token(sp.ID, "12.1"),
     Token(sp.COLON),
     Token(sp.ID, "12.6"),
-    Token(sp.ON),
-    Token(sp.ID, "debug"),
-    Token(sp.OFF),
-    Token(sp.ID, "qt_4"),
+]
+
+complex_dep1 = [
     Token(sp.DEP),
-    Token(sp.ID, "stackwalker"),
-    Token(sp.AT),
-    Token(sp.ID, "8.1_1e"),
+    Token(sp.ID, "_openmpi"),
+    Token(sp.VER, "@1.2"),
+    Token(sp.COLON),
+    Token(sp.ID, "1.4"),
+    Token(sp.COMMA),
+    Token(sp.ID, "1.6"),
 ]
 
-# Another sample lexer output with a kv pair.
-kv_lex = [
-    Token(sp.ID, "mvapich_foo"),
-    Token(sp.ID, "debug"),
-    Token(sp.EQ),
-    Token(sp.VAL, "4"),
+complex_dep1_space = [
     Token(sp.DEP),
     Token(sp.ID, "_openmpi"),
-    Token(sp.AT),
+    Token(sp.VER, "@"),
     Token(sp.ID, "1.2"),
     Token(sp.COLON),
     Token(sp.ID, "1.4"),
     Token(sp.COMMA),
     Token(sp.ID, "1.6"),
-    Token(sp.PCT),
-    Token(sp.ID, "intel"),
-    Token(sp.AT),
-    Token(sp.ID, "12.1"),
-    Token(sp.COLON),
-    Token(sp.ID, "12.6"),
+]
+
+complex_dep1_var = [
     Token(sp.ON),
     Token(sp.ID, "debug"),
     Token(sp.OFF),
     Token(sp.ID, "qt_4"),
+]
+
+complex_dep2 = [
+    Token(sp.DEP),
+    Token(sp.ID, "stackwalker"),
+    Token(sp.VER, "@8.1_1e"),
+]
+
+complex_dep2_space = [
     Token(sp.DEP),
     Token(sp.ID, "stackwalker"),
-    Token(sp.AT),
+    Token(sp.VER, "@"),
     Token(sp.ID, "8.1_1e"),
 ]
 
+# Sample output from complex lexing
+complex_lex = (
+    complex_root
+    + complex_dep1
+    + complex_compiler
+    + complex_compiler_v
+    + complex_dep1_var
+    + complex_dep2
+)
+
+# Another sample lexer output with a kv pair.
+kv_lex = (
+    kv_root
+    + complex_dep1
+    + complex_compiler
+    + complex_compiler_v_space
+    + complex_dep1_var
+    + complex_dep2_space
+)
+
 
 class TestSpecSyntax(object):
     # ========================================================================
@@ -120,7 +154,7 @@ def check_lex(self, tokens, spec):
         lex_output = sp.SpecLexer().lex(spec)
         assert len(tokens) == len(lex_output), "unexpected number of tokens"
         for tok, spec_tok in zip(tokens, lex_output):
-            if tok.type == sp.ID or tok.type == sp.VAL:
+            if tok.type in (sp.ID, sp.VAL, sp.VER):
                 assert tok == spec_tok
             else:
                 # Only check the type for non-identifiers.
@@ -716,14 +750,22 @@ def test_minimal_spaces(self):
         )
 
     def test_spaces_between_dependences(self):
+        lex_key = (
+            complex_root
+            + complex_dep1
+            + complex_compiler
+            + complex_compiler_v
+            + complex_dep1_var
+            + complex_dep2_space
+        )
         self.check_lex(
-            complex_lex,
+            lex_key,
             "mvapich_foo "
             "^_openmpi@1.2:1.4,1.6%intel@12.1:12.6+debug -qt_4 "
             "^stackwalker @ 8.1_1e",
         )
         self.check_lex(
-            complex_lex,
+            lex_key,
             "mvapich_foo "
             "^_openmpi@1.2:1.4,1.6%intel@12.1:12.6+debug~qt_4 "
             "^stackwalker @ 8.1_1e",
@@ -738,14 +780,30 @@ def test_spaces_between_options(self):
         )
 
     def test_way_too_many_spaces(self):
+        lex_key = (
+            complex_root
+            + complex_dep1
+            + complex_compiler
+            + complex_compiler_v_space
+            + complex_dep1_var
+            + complex_dep2_space
+        )
         self.check_lex(
-            complex_lex,
+            lex_key,
             "mvapich_foo "
             "^ _openmpi @1.2 : 1.4 , 1.6 % intel @ 12.1 : 12.6 + debug - qt_4 "
             "^ stackwalker @ 8.1_1e",
         )
+        lex_key = (
+            complex_root
+            + complex_dep1
+            + complex_compiler
+            + complex_compiler_v_space
+            + complex_dep1_var
+            + complex_dep2_space
+        )
         self.check_lex(
-            complex_lex,
+            lex_key,
             "mvapich_foo "
             "^ _openmpi @1.2 : 1.4 , 1.6 % intel @ 12.1 : 12.6 + debug ~ qt_4 "
             "^ stackwalker @ 8.1_1e",
@@ -838,6 +896,10 @@ def test_compare_abstract_specs(self):
             # Check that we can compare without raising an error
             assert a <= b or b < a
 
+    def test_git_ref_specs_with_variants(self):
+        spec_str = "develop-branch-version@git.{h}=develop+var1+var2".format(h="a" * 40)
+        self.check_parse(spec_str)
+
     def test_git_ref_spec_equivalences(self, mock_packages, mock_stage):
         s1 = sp.Spec("develop-branch-version@git.{hash}=develop".format(hash="a" * 40))
         s2 = sp.Spec("develop-branch-version@git.{hash}=develop".format(hash="b" * 40))