From e0e9189355bb4f3380cad93a63dae79aa6af1fbc Mon Sep 17 00:00:00 2001
From: Tyler Akins <fidian@rumkin.com>
Date: Sun, 9 Apr 2023 18:53:55 -0500
Subject: [PATCH] Bugfixes, pretty printing, new tests added

Spec result: 108 specs pass, 6 skipped, 67 fail
---
 mo                                            | 111 ++++++++++++++++--
 run-spec.js                                   | 102 ++++++++++------
 run-tests                                     |   2 +-
 tests/fixtures/standalone-indentation.partial |   3 +
 tests/list-contexts                           |  14 +++
 tests/multi-line-partial                      |   3 +-
 tests/standalone-indentation                  |  24 ++++
 7 files changed, 210 insertions(+), 49 deletions(-)
 create mode 100644 tests/fixtures/standalone-indentation.partial
 create mode 100755 tests/list-contexts
 create mode 100755 tests/standalone-indentation

diff --git a/mo b/mo
index bdfd9a4..cc4f9dd 100755
--- a/mo
+++ b/mo
@@ -786,7 +786,7 @@ mo::parsePartial() {
         moR=$'\r'
         moIndentation="$moN${moPrevious//$moR/$moN}"
         moIndentation=${moIndentation##*$moN}
-        mo::debug "Adding indentation: '$moIndentation'"
+        mo::debug "Adding indentation to partial: '$moIndentation'"
         mo::standaloneProcessBefore moPrevious "$moPrevious"
         mo::standaloneProcessAfter moContent "$moContent"
         moStandaloneContent=$'\n'
@@ -810,6 +810,8 @@ mo::parsePartial() {
                     exit 1
                 fi
 
+                mo::indentLines moResult "$moResult" "$moIndentation"
+
                 # Delimiters are reset when loading a new partial
                 mo::parse moResult "$moResult" "$moCurrent" "" "{{" "}}" "" $'\n'
 
@@ -823,7 +825,7 @@ mo::parsePartial() {
             exit 1
         fi
 
-        mo::indentLines moResult "${moResult%.}" "$moIndentation"
+        moResult=${moResult%.}
     fi
 
     local "$1" && mo::indirectArray "$1" "$moPrevious$moResult" "$moContent" "$moStandaloneContent"
@@ -1317,6 +1319,42 @@ mo::isArray() {
 }
 
 
+# Internal: Determine if an array index exists.
+#
+# $1 - Variable name to check
+# $2 - The index to check
+#
+# Has to check if the variable is an array and if the index is valid for that
+# type of array.
+#
+# Returns true (0) if everything was ok, 1 if there's any condition that fails.
+mo::isArrayIndexValid() {
+    local moDeclare moTest
+
+    moDeclare=$(declare -p "$1")
+    moTest=""
+
+    if [[ "${moDeclare:0:10}" == "declare -a" ]]; then
+        # Numerically indexed array - must check if the index looks like a
+        # number because using a string to index a numerically indexed array
+        # will appear like it worked.
+        if [[ "$2" == "0" ]] || [[ "$2" =~ ^[1-9][0-9]*$ ]]; then
+            # Index looks like a number
+            eval "moTest=\"\${$1[$2]+ok}\""
+        fi
+    elif [[ "${moDeclare:0:10}" == "declare -A" ]]; then
+        # Associative array
+        eval "moTest=\"\${$1[$2]+ok}\""
+    fi
+
+    if [[ -n "$moTest" ]]; then
+        return 0;
+    fi
+
+    return 1
+}
+
+
 # Internal: Determine if a variable is assigned, even if it is assigned an empty
 # value.
 #
@@ -1442,24 +1480,24 @@ mo::evaluateListOfSingles() {
 #
 # Returns nothing
 mo::evaluateSingle() {
-    local moResult moCurrent moVarNameParts moType moArg
+    local moResult moCurrent moType moArg
 
     moCurrent=$2
     moType=$3
     moArg=$4
-    mo::debug "Evaluating $moType: $moArg"
+
+    mo::debug "Evaluating $moType: $moArg ($moCurrent)"
 
     if [[ "$moType" == "VALUE" ]]; then
         moResult=$moArg
     elif [[ "$moArg" == "." ]]; then
-        mo::evaluateVariable moResult "$moCurrent"
+        mo::evaluateVariable moResult "$moCurrent" ""
     elif [[ "$moArg" == "@key" ]]; then
         mo::evaluateKey moResult "$moCurrent"
     elif mo::isFunction "$moArg"; then
         mo::evaluateFunction moResult "" "$moArg"
     else
-        mo::split moVarNameParts "$moArg" .
-        mo::evaluateVariable moResult "$moArg"
+        mo::evaluateVariable moResult "$moArg" "$moCurrent"
     fi
 
     local "$1" && mo::indirect "$1" "$moResult"
@@ -1491,16 +1529,19 @@ mo::evaluateKey() {
 #
 # $1 - Destination variable name
 # $2 - Variable name
+# $3 - Current value
 #
 # Returns nothing.
 mo::evaluateVariable() {
     local moResult moCurrent moArg moNameParts moJoined moKey moValue
 
     moArg=$2
+    moCurrent=$3
     moResult=""
-    mo::split moNameParts "$moArg" .
+    mo::findVariableName moNameParts "$moArg" "$moCurrent"
+    mo::debug "Evaluate variable ($moArg + $moCurrent): ${moNameParts[*]}"
 
-    if [[ -z "${moNameParts[1]-}" ]]; then
+    if [[ -z "${moNameParts[1]}" ]]; then
         if mo::isArray "$moArg"; then
             eval mo::join moResult "," "\${$moArg[@]}"
         else
@@ -1523,6 +1564,58 @@ mo::evaluateVariable() {
 }
 
 
+# Internal: Find the name of a variable to use
+#
+# $1 - Destination variable name, receives an array
+# $2 - Variable name from the template
+# $3 - The name of the "current value", from block parsing
+#
+# The array contains the following values
+#     [0] - Variable name
+#     [1] - Array index, or empty string
+#
+# Example variables
+#     a="a"
+#     b="b"
+#     c=("c.0" "c.1")
+#     d=([b]="d.b" [d]="d.d")
+#
+# Given these inputs, produce these outputs
+#     a c => a
+#     a c.0 => a
+#     b d => d.b
+#     b d.d => d.b
+#     a d => d.a
+#     a d.d => d.a
+#     c.0 d => c.0
+#     d.b d => d.b
+# Returns nothing.
+mo::findVariableName() {
+    local moVar moCurrent moNameParts moResultBase moResultIndex
+
+    moVar=$2
+    moCurrent=$3
+    moResultBase=$moVar
+    moResultIndex=""
+
+    if [[ "$moVar" == *.* ]]; then
+        mo::debug "Find variable name; name has dot: $moVar"
+        moResultBase=${moVar%%.*}
+        moResultIndex=${moVar#*.}
+    elif [[ -n "$moCurrent" ]]; then
+        moCurrent=${moCurrent%%.*}
+        mo::debug "Find variable name; look in array: $moCurrent"
+
+        if mo::isArrayIndexValid "$moCurrent" "$moVar"; then
+            moResultBase=$moCurrent
+            moResultIndex=$moVar
+        fi
+    fi
+
+    local "$1" && mo::indirectArray "$1" "$moResultBase" "$moResultIndex"
+}
+
+
 # Internal: Join / implode an array
 #
 # $1    - Variable name to receive the joined content
diff --git a/run-spec.js b/run-spec.js
index 8e50ae1..ad41931 100644
--- a/run-spec.js
+++ b/run-spec.js
@@ -11,28 +11,45 @@ const fsPromises = require("fs").promises;
 //
 // To override any test property, just define that property.
 const testOverrides = {
-    'Interpolation -> HTML Escaping': {
-        skip: 'HTML escaping is not supported'
+    "Interpolation -> HTML Escaping": {
+        skip: "HTML escaping is not supported"
     },
-    'Interpolation -> Implicit Iterators - HTML Escaping': {
-        skip: 'HTML escaping is not supported'
+    "Interpolation -> Implicit Iterators - HTML Escaping": {
+        skip: "HTML escaping is not supported"
     },
-    'Lambdas -> Escaping': {
-        skip: 'HTML escaping is not supported'
+    "Lambdas -> Escaping": {
+        skip: "HTML escaping is not supported"
     },
-    'Sections -> Dotted Names - Broken Chains': {
+    "Sections -> Deeply Nested Contexts": {
+        skip: "Nested objects are not supported"
+    },
+    "Sections -> Dotted Names - Broken Chains": {
         // Complex objects are not supported
         template: `"{{#a.b}}Here{{/a.b}}" == ""`
     },
-    'Sections -> Dotted Names - Falsey': {
+    "Sections -> Dotted Names - Falsey": {
         // Complex objects are not supported
         data: { a: { b: false } },
         template: `"{{#a.b}}Here{{/a.b}}" == ""`
     },
-    'Sections -> Dotted Names - Truthy': {
+    "Sections -> Dotted Names - Truthy": {
         // Complex objects are not supported
         data: { a: { b: true } },
         template: `"{{#a.b}}Here{{/a.b}}" == "Here"`
+    },
+    "Sections -> Implicit Iterator - Array": {
+        skip: "Nested arrays are not supported"
+    },
+    "Sections -> List": {
+        // Arrays of objects are not supported
+        data: { list: [1, 2, 3] },
+        template: `"{{#list}}{{.}}{{/list}}"`
+    },
+    "Sections -> List Context": {
+        skip: "Deeply nested objects are not supported"
+    },
+    "Sections -> List Contexts": {
+        skip: "Deeply nested objects are not supported"
     }
 };
 
@@ -110,7 +127,7 @@ function addToEnvironmentObjectConvertedToAssociativeArray(name, value) {
     const values = [];
 
     for (const [k, v] of Object.entries(value)) {
-        if (typeof v === 'object') {
+        if (typeof v === "object") {
             if (v) {
                 // An object - abort
                 return `# ${name}.${k} is an object that can not be converted to an associative array`;
@@ -123,7 +140,7 @@ function addToEnvironmentObjectConvertedToAssociativeArray(name, value) {
         }
     }
 
-    return `declare -A ${name}\n${name}=(${values.join(' ')})`;
+    return `declare -A ${name}\n${name}=(${values.join(" ")})`;
 }
 
 function addToEnvironmentObject(name, value) {
@@ -134,10 +151,7 @@ function addToEnvironmentObject(name, value) {
 
     // Sometimes the __tag__ property of the code in the lambdas may be
     // missing. Compensate by detecting commonly defined languages.
-    if (
-        (value.__tag__ === "code") ||
-        (value.ruby && value.php && value.perl)
-    ) {
+    if (value.__tag__ === "code" || (value.ruby && value.php && value.perl)) {
         if (value.bash) {
             return `${name}() { ${value.bash}; }`;
         }
@@ -145,7 +159,6 @@ function addToEnvironmentObject(name, value) {
         return `${name}() { perl -e 'print ((${value.perl})->("'"$1"'"))'; }`;
     }
 
-
     return addToEnvironmentObjectConvertedToAssociativeArray(name, value);
 }
 
@@ -201,16 +214,20 @@ function setupEnvironment(test) {
 
 function executeScript(test) {
     return new Promise((resolve) => {
-        exec("bash spec-runner/spec-script 2>&1", {
-            timeout: 2000
-        }, (err, stdout) => {
-            if (err) {
-                test.scriptError = err.toString();
-            }
+        exec(
+            "bash spec-runner/spec-script 2>&1",
+            {
+                timeout: 2000
+            },
+            (err, stdout) => {
+                if (err) {
+                    test.scriptError = err.toString();
+                }
 
-            test.output = stdout;
-            resolve();
-        });
+                test.output = stdout;
+                resolve();
+            }
+        );
     });
 }
 
@@ -232,9 +249,9 @@ function detectFailure(test) {
 
 function showFailureDetails(test) {
     console.log(`FAILURE: ${test.fullName}`);
-    console.log('');
+    console.log("");
     console.log(test.desc);
-    console.log('');
+    console.log("");
     console.log(JSON.stringify(test, null, 4));
 }
 
@@ -261,12 +278,12 @@ function runTest(testSet, test) {
     test.script = buildScript(test);
 
     if (test.skip) {
-        debug('Skipping test:', testSet.fullName, `$(${test.skip})`);
+        debug("Skipping test:", testSet.fullName, `$(${test.skip})`);
 
         return Promise.resolve();
     }
 
-    debug('Running test:', testSet.fullName);
+    debug("Running test:", testSet.fullName);
 
     return setupEnvironment(test)
         .then(() => executeScript(test))
@@ -303,7 +320,9 @@ function processSpecFile(filename) {
                     testSet.pass += 1;
                 }
             }
-            console.log(`### ${testSet.name} Results = ${testSet.pass} passed, ${testSet.fail} failed, ${testSet.skip} skipped`);
+            console.log(
+                `### ${testSet.name} Results = ${testSet.pass} passed, ${testSet.fail} failed, ${testSet.skip} skipped`
+            );
 
             return testSet;
         });
@@ -318,11 +337,14 @@ if (process.argv.length < 3) {
 
 processArraySequentially(process.argv.slice(2), processSpecFile).then(
     (result) => {
-        console.log('=========================================');
-        console.log('');
-        console.log('Failed Test Summary');
-        console.log('');
-        let pass = 0, fail = 0, skip = 0, total = 0;
+        console.log("=========================================");
+        console.log("");
+        console.log("Failed Test Summary");
+        console.log("");
+        let pass = 0,
+            fail = 0,
+            skip = 0,
+            total = 0;
 
         for (const testSet of result) {
             pass += testSet.pass;
@@ -330,7 +352,9 @@ processArraySequentially(process.argv.slice(2), processSpecFile).then(
             skip += testSet.skip;
             total += testSet.tests.length;
 
-            console.log(`* ${testSet.name}: ${testSet.tests.length} total, ${testSet.pass} pass, ${testSet.fail} fail, ${testSet.skip} skip`);
+            console.log(
+                `* ${testSet.name}: ${testSet.tests.length} total, ${testSet.pass} pass, ${testSet.fail} fail, ${testSet.skip} skip`
+            );
 
             for (const test of testSet.tests) {
                 if (test.isFailure) {
@@ -339,8 +363,10 @@ processArraySequentially(process.argv.slice(2), processSpecFile).then(
             }
         }
 
-        console.log('');
-        console.log(`Final result: ${total} total, ${pass} pass, ${fail} fail, ${skip} skip`);
+        console.log("");
+        console.log(
+            `Final result: ${total} total, ${pass} pass, ${fail} fail, ${skip} skip`
+        );
 
         if (fail) {
             process.exit(1);
diff --git a/run-tests b/run-tests
index be45782..84b0212 100755
--- a/run-tests
+++ b/run-tests
@@ -50,7 +50,7 @@ runTest() (
         echo "Actual:"
         echo "$testActual"
 
-        if [[ -n "${MO_DEBUG-}" ]]; then
+        if [[ -n "${MO_DEBUG_TEST-}" ]]; then
             declare -p testExpected
             declare -p testActual
         fi
diff --git a/tests/fixtures/standalone-indentation.partial b/tests/fixtures/standalone-indentation.partial
new file mode 100644
index 0000000..96d7a30
--- /dev/null
+++ b/tests/fixtures/standalone-indentation.partial
@@ -0,0 +1,3 @@
+|
+{{content}}
+|
diff --git a/tests/list-contexts b/tests/list-contexts
new file mode 100755
index 0000000..97c9a70
--- /dev/null
+++ b/tests/list-contexts
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+cd "${0%/*}" || exit 1
+. ../run-tests
+
+a=foo
+b=wrong
+declare -A sec
+sec=([b]="bar")
+declare -A c
+c=([d]="baz")
+template="{{#sec}}{{a}} {{b}} {{c.d}}{{/sec}}"
+expected="foo bar baz"
+
+runTest
diff --git a/tests/multi-line-partial b/tests/multi-line-partial
index 95772b0..7137f5b 100755
--- a/tests/multi-line-partial
+++ b/tests/multi-line-partial
@@ -24,8 +24,9 @@ line 2
 Indented:
 
     line 1
-    line 2
+line 2
 EOF
+    # This one looks odd, but if you check the spec spec/specs/partials.yaml, name "Standalone Indentation" (mirrors "standalone-indentation" in tests/), then the spec clearly shows that the indentation is applied before rendering.
 }
 
 runTest
diff --git a/tests/standalone-indentation b/tests/standalone-indentation
new file mode 100755
index 0000000..d3b51c0
--- /dev/null
+++ b/tests/standalone-indentation
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+cd "${0%/*}" || exit 1
+. ../run-tests
+
+content=$'<\n->'
+template() {
+    cat <<EOF
+\
+ {{>fixtures/standalone-indentation.partial}}
+/
+EOF
+}
+expected() {
+    cat <<EOF
+\
+ |
+ <
+->
+ |
+/
+EOF
+}
+
+runTest