BareGit

Keep link URLs verbatim in macro expansion

User-defined macro bodies were expanded by string-substituting argument
values and then re-parsing the result. URLs containing `_` (and any
other markdown-significant characters) were therefore re-interpreted as
emphasis when spliced into the `link` template. Parse the body template
once and resolve `%argname` references against a scoped arg map at
evaluation time, so argument values are inserted opaquely.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun May 10 15:40:04 2026 -0700
Commit: b620d9c8b50c913fd92cce53c27cac3bdab93a67

Changes

diff --git a/include/macro_engine.h b/include/macro_engine.h
index d84e9f5..6bc554b 100644
--- a/include/macro_engine.h
+++ b/include/macro_engine.h
@@ -52,6 +52,12 @@ public:
     std::string evaluateMacro(const Macro& macro);
 
 private:
+    using ArgScope = std::map<std::string, std::string>;
+
+    std::string evaluateInScope(const Node& node, const ArgScope& scope);
+    std::string evaluateMacroInScope(const Macro& macro,
+                                     const ArgScope& scope);
+
     std::map<std::string, MacroDefinition> macros_;
 };
 
diff --git a/src/macro_engine.cpp b/src/macro_engine.cpp
index 3add8de..185ef6c 100644
--- a/src/macro_engine.cpp
+++ b/src/macro_engine.cpp
@@ -33,19 +33,6 @@ std::vector<std::string> split(const std::string& s, char delimiter)
     return tokens;
 }
 
-// Helper to replace all occurrences of a substring
-std::string replace_all(std::string str, const std::string& from,
-                        const std::string& to)
-{
-    size_t start_pos = 0;
-    while((start_pos = str.find(from, start_pos)) != std::string::npos)
-    {
-        str.replace(start_pos, from.length(), to);
-        start_pos += to.length();
-    }
-    return str;
-}
-
 } // namespace
 
 Evaluator::Evaluator()
@@ -84,9 +71,21 @@ void Evaluator::defineIntrinsic(const std::string& name, MacroCallback callback)
 }
 
 std::string Evaluator::evaluate(const Node& node)
+{
+    ArgScope empty_scope;
+    return evaluateInScope(node, empty_scope);
+}
+
+std::string Evaluator::evaluateMacro(const Macro& macro)
+{
+    ArgScope empty_scope;
+    return evaluateMacroInScope(macro, empty_scope);
+}
+
+std::string Evaluator::evaluateInScope(const Node& node, const ArgScope& scope)
 {
     return std::visit(
-        [this](auto&& arg) -> std::string
+        [this, &scope](auto&& arg) -> std::string
         {
             using T = std::decay_t<decltype(arg)>;
             if constexpr(std::is_same_v<T, Text>)
@@ -95,14 +94,26 @@ std::string Evaluator::evaluate(const Node& node)
             }
             else if constexpr(std::is_same_v<T, Macro>)
             {
-                return this->evaluateMacro(arg);
+                // A macro reference with no arguments may be an argument
+                // placeholder (e.g. `%url` inside a user-defined body). In
+                // that case substitute the already-evaluated argument value
+                // verbatim, without re-parsing it as MacroDown source.
+                if(arg.arguments.empty())
+                {
+                    auto it = scope.find(arg.name);
+                    if(it != scope.end())
+                    {
+                        return it->second;
+                    }
+                }
+                return this->evaluateMacroInScope(arg, scope);
             }
             else if constexpr(std::is_same_v<T, Group>)
             {
                 std::string result;
                 for(const auto& child : arg.children)
                 {
-                    result += this->evaluate(*child);
+                    result += this->evaluateInScope(*child, scope);
                 }
                 return result;
             }
@@ -111,7 +122,8 @@ std::string Evaluator::evaluate(const Node& node)
         node.data);
 }
 
-std::string Evaluator::evaluateMacro(const Macro& macro)
+std::string Evaluator::evaluateMacroInScope(const Macro& macro,
+                                            const ArgScope& outer_scope)
 {
     auto it = macros_.find(macro.name);
     if(it == macros_.end())
@@ -119,7 +131,7 @@ std::string Evaluator::evaluateMacro(const Macro& macro)
         std::string result = "%" + macro.name;
         for(const auto& arg : macro.arguments)
         {
-            result += "{" + evaluate(*arg) + "}";
+            result += "{" + evaluateInScope(*arg, outer_scope) + "}";
         }
         return result;
     }
@@ -129,7 +141,7 @@ std::string Evaluator::evaluateMacro(const Macro& macro)
     std::vector<std::string> evaluated_args;
     for(const auto& arg : macro.arguments)
     {
-        evaluated_args.push_back(evaluate(*arg));
+        evaluated_args.push_back(evaluateInScope(*arg, outer_scope));
     }
 
     if(def.is_intrinsic)
@@ -138,22 +150,24 @@ std::string Evaluator::evaluateMacro(const Macro& macro)
     }
     else
     {
-        std::string body = def.body;
-
+        // Parse the body template once. Argument values are NOT substituted
+        // textually into the body — they're bound in a new scope and looked
+        // up when evaluation reaches a matching `%argname` reference. This
+        // keeps argument values opaque so characters like `_` inside a URL
+        // aren't re-interpreted as markdown.
+        auto body_nodes = Parser::parse(def.body);
+
+        ArgScope new_scope;
         for(size_t i = 0; i < def.arg_names.size(); ++i)
         {
-            std::string placeholder = "%" + def.arg_names[i];
-            std::string value =
+            new_scope[def.arg_names[i]] =
                 (i < evaluated_args.size()) ? evaluated_args[i] : "";
-            body = replace_all(body, placeholder, value);
         }
 
-        auto nodes = Parser::parse(body);
-
         std::string result;
-        for(const auto& n : nodes)
+        for(const auto& n : body_nodes)
         {
-            result += evaluate(*n);
+            result += evaluateInScope(*n, new_scope);
         }
         return result;
     }
diff --git a/tests/test_macrodown.cpp b/tests/test_macrodown.cpp
index 61de739..3acad7a 100644
--- a/tests/test_macrodown.cpp
+++ b/tests/test_macrodown.cpp
@@ -169,3 +169,12 @@ ccc
         "<pre><code>* aaa\n\nbbb\n</code></pre>\n<p>ccc</p>\n";
     EXPECT_EQ(md.render(*md.parse(input)), expected);
 }
+
+TEST(MacroDownTest, URLsAreVerbatim)
+{
+    MacroDown md;
+    std::string input = R"([aaa](http://its_a_test.com/))";
+    std::string expected =
+        "<p><a href=\"http://its_a_test.com/\">aaa</a></p>\n";
+    EXPECT_EQ(md.render(*md.parse(input)), expected);
+}