[dvsim] Document and slightly improve subst_wildcards in utils.py

When messing around with config files, I managed to trigger an
infinite loop in the subst_wildcards function. This patch fixes that,
and also adds a big documentation comment and tests.

Changes:

  - Document exactly what subst_wildcards does (it's quite
    complicated!)

  - Spot circular recursion rather than blowing the call stack.

  - Fix some odd behaviour in {eval_cmd} support.

    The previous code would transform "foo {eval_cmd} echo bar" to
    "bar", ignoring the "foo" prefix entirely. Similarly, it would
    transform "{eval_cmd}xecho foo" to "foo" (skipping over the 'x',
    which it assumed to be a space).

  - Be more explicit about how values get stringified

  - Make the interaction between {eval_cmd} and ignore_error=True a
    more uniform.

    The previous code performed partial evaluation when ignore_error
    was true except when doing {eval_cmd}, when it would discard the
    partially evaluated command string.

  - Add some simple tests.

    These correspond to documentation examples. You can run them with
    pytest. Note that this doesn't add a dependency on pytest unless
    you actually want to run the tests (since Python will merrily
    "parse" code where the named modules aren't in scope!)

Differences in behaviour from the original code:

  - Clearer eval_cmd behavior (see above)

  - Circular references are now spotted. Before, a call to

      subst_wildcards('{a}', {'a': '{b}', 'b': '{a}'})

    would cause an infinite loop. This is the sort of thing that you
    can trigger by a mistake in your config files, and what caused me
    to look at the function in the first place. Now it reports an
    error (regardless of the value of ignore_error).

  - List items are now stringified recursively in
    _stringify_wildcard_value. This makes rules like {'a': ['b', 10]}
    work. The functionality isn't used in dvsim at the moment (because
    all lists are lists of strings), but it's probably a bit cleaner.

  - Computed wildcard names are now possible. Probably not a
    particularly useful feature, but it comes for free with the
    "iterate over matches from the left" implementation, so it can't
    hurt to support explicitly.

Signed-off-by: Rupert Swarbrick <rswarbrick@lowrisc.org>
diff --git a/util/dvsim/utils_test.py b/util/dvsim/utils_test.py
new file mode 100644
index 0000000..d8267b7
--- /dev/null
+++ b/util/dvsim/utils_test.py
@@ -0,0 +1,77 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+
+'''pytest-based testing for functions in utils.py'''
+
+import os
+import pytest
+from .utils import _subst_wildcards, subst_wildcards
+
+
+def test_subst_wildcards():
+    '''Pytest-compatible test for the subst_wildcards function.'''
+    # Basic checks
+    assert subst_wildcards('foo {x} baz', {'x': 'bar'}) == 'foo bar baz'
+
+    # Stringify
+    assert (subst_wildcards('{a}, {b}, {c}, {d}',
+                            {'a': 'a', 'b': True, 'c': 42, 'd': ['{b}', 10]}) ==
+            'a, 1, 42, 1 10')
+
+    # Ignored wildcards (with or without a match in mdict)
+    assert (subst_wildcards('{a} {b}', {'a': 'aye', 'b': 'bee'},
+                            ignored_wildcards=['a']) ==
+            '{a} bee')
+    assert (subst_wildcards('{a} {b}', {'b': 'bee'},
+                            ignored_wildcards=['a']) ==
+            '{a} bee')
+
+    # Environment variables. We will always have PWD and can probably assume
+    # that this won't itself have any braced substrings.
+    assert (subst_wildcards('{PWD}', {}) == os.environ['PWD'])
+
+    # Missing variable with ignore_error=False, running _subst_wildcards
+    # instead so that we can catch the error. (We assume that 'biggles' isn't
+    # in the environment)
+    with pytest.raises(ValueError) as excinfo:
+        _subst_wildcards('{biggles} {b}', {'b': 'bee'}, [], False, [])
+    assert "unknown wildcard, '{biggles}'" in str(excinfo.value)
+
+    # ignore_error=True.
+    assert (subst_wildcards('{biggles} {b}', {'b': 'bee'},
+                            ignore_error=True) ==
+            '{biggles} bee')
+
+    # Check we support (non-circular) recursion
+    assert (subst_wildcards('{a}', {'a': '{b}', 'b': 'c'}) == 'c')
+
+    # Check we spot circular recursion
+    with pytest.raises(ValueError) as excinfo:
+        _subst_wildcards('{a}', {'a': '{b}', 'b': '{a}'}, [], False, [])
+    assert "circular expansion of wildcard '{a}'" in str(excinfo.value)
+
+    # Check we also complain about circular recursion with ignore_error
+    with pytest.raises(ValueError) as excinfo:
+        _subst_wildcards('{a}', {'a': '{b}', 'b': '{a}'}, [], True, [])
+    assert "circular expansion of wildcard '{a}'" in str(excinfo.value)
+
+    # Computed variable names (probably not a great idea, but it's probably
+    # good to check this works the way we think)
+    assert subst_wildcards('{a}b}', {'a': 'a {', 'b': 'bee'}) == 'a bee'
+
+    # Some eval_cmd calls (using echo, which should always work)
+    assert (subst_wildcards('{eval_cmd}echo foo {b}', {'b': 'bar'}) ==
+            'foo bar')
+
+    # Make sure that nested commands work
+    assert (subst_wildcards('{eval_cmd} {eval_cmd} echo echo a', {}) == 'a')
+
+    # Recursive expansion
+    assert (subst_wildcards('{var}',
+                            {
+                                'var': '{{foo}_xyz_{bar}}',
+                                'foo': 'p',
+                                'bar': 'q',
+                                'p_xyz_q': 'baz'
+                            }) == 'baz')