RST reader: Fix nested placeholder resolution for inline elements (#11753)

Given RST like

    .. _target:

    See |sub|.

    .. |sub| replace:: `text <target_>`_

'pandoc -f rst -t html' produces

    <div id="target">
    <p>See <a href="##REF##target">text</a>.</p>
    </div>

instead of the expected

    <div id="target">
    <p>See <a href="#target">text</a>.</p>
    </div>

It formerly worked and regressed with c8fda8f4d ("RST reader: Use a new
one-pass parsing strategy."), release 3.6.

What happens is that during parsing pass 1 the `replace::` value `text
<target_>`_ is parsed to

    Link nullAttr [Str "text"] ("##REF##target", "")

and is stored in ParserState's substitution table. Separately, '|sub|'
usage is parsed to

    Link nullAttr [Str "|sub|"] ("##SUBST##|sub|", "")

and is stored in the document tree. resolveReferences then replaces the
placeholder in the document node with substitution table node during
walkM. However, the freshly substituted ##REF## placeholder was not
revisited further, and appeared unresolved in the output.

To fix it, we resolve the node recursively until the result contains no
more placeholder. We must protect from self-references to avoid
endless recursion.
This commit is contained in:
Tobias Deiminger
2026-07-12 16:35:37 +02:00
committed by GitHub
parent ce37e24c6a
commit 5dd191098d
2 changed files with 55 additions and 10 deletions
+26 -10
View File
@@ -25,6 +25,7 @@ import Data.List (deleteFirstsBy, elemIndex, nub, partition, sort, transpose)
import qualified Data.Map as M
import Data.Maybe (fromMaybe, maybeToList, isJust, isNothing, catMaybes)
import Data.Sequence (ViewR (..), viewr)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Text.Printf (printf)
@@ -186,7 +187,10 @@ resolveBlockSubstitutions (Para [Link _attr ils (s,_)])
resolveBlockSubstitutions x = return x
resolveReferences :: PandocMonad m => Inline -> RSTParser m Inline
resolveReferences x@(Link _ ils (s,_))
resolveReferences = resolveReferences' Set.empty
resolveReferences' :: PandocMonad m => Set.Set Key -> Inline -> RSTParser m Inline
resolveReferences' seen x@(Link _ ils (s,_))
| Just ref <- T.stripPrefix "##REF##" s = do
let isAnonKey (Key (T.uncons -> Just ('_',_))) = True
isAnonKey _ = False
@@ -199,11 +203,17 @@ resolveReferences x@(Link _ ils (s,_))
[] -> mzero -- TODO log?
(k:_) -> return k
else return $ toKey ref
((src,tit), attr) <- lookupKey [] key
-- if anonymous link, remove key so it won't be used again
when (isAnonKey key) $ updateState $ \st ->
st{ stateKeys = M.delete key keyTable }
return $ Link attr ils (src, tit)
if key `Set.member` seen
then do
pos <- getPosition
let Key key' = key
logMessage $ CircularReference key' pos
return x
else do
((src,tit), attr) <- lookupKey [] key
when (isAnonKey key) $ updateState $ \st ->
st{ stateKeys = M.delete key keyTable }
resolveReferences' (Set.insert key seen) (Link attr ils (src, tit))
| Just ref <- T.stripPrefix "##NOTE##" s = do
state <- getState
let notes = stateNotes state
@@ -229,18 +239,24 @@ resolveReferences x@(Link _ ils (s,_))
| Just ref <- T.stripPrefix "##SUBST##" s = do
substTable <- stateSubstitutions <$> getState
let key@(Key key') = toKey $ stripFirstAndLast ref
case M.lookup key substTable of
if key `Set.member` seen
then do
pos <- getPosition
logMessage $ CircularReference key' pos
return $ Span ("",[],[]) ils
else case M.lookup key substTable of
Nothing -> do
pos <- getPosition
logMessage $ ReferenceNotFound (tshow key') pos
return $ Span ("",[],[]) ils
Just target -> case
B.toList target of
Just target -> do
resolved <- case B.toList target of
[Para [t]] -> return t
[Para xs] -> return $ Span nullAttr xs
bls -> return $ Span nullAttr $ blocksToInlines bls
resolveReferences' (Set.insert key seen) resolved
| otherwise = return x
resolveReferences x = return x
resolveReferences' _ x = return x
parseCitation :: PandocMonad m
=> (Text, Text) -> RSTParser m (Inlines, [Blocks])
+29
View File
@@ -220,5 +220,34 @@ tests = [ "line block with blank line" =:
, "include newlines" =:
"**before\nafter**" =?>
para (strong (text "before\nafter"))
, "bare reference reusing a named target resolves correctly" =:
T.unlines
[ ".. _target:"
, ""
, "See `alias <target_>`_ and again alias_."
] =?>
divWith ("target",[],[])
(para ("See " <> link "#target" "" "alias" <> " and again " <>
link "#target" "" "alias" <> "."))
, "self-referencing named target does not loop forever" =:
"See `a <a_>`_." =?>
para ("See " <> link "##REF##a" "" "a" <> ".")
, "reference to internal target embedded in a substitution" =:
T.unlines
[ ".. _target:"
, ""
, "See |sub|."
, ""
, ".. |sub| replace:: `text <target_>`_"
] =?>
divWith ("target",[],[])
(para ("See " <> link "#target" "" "text" <> "."))
, "circular substitution does not loop forever" =:
T.unlines
[ ".. |a| replace:: |a|"
, ""
, "Test |a| here."
] =?>
para ("Test " <> spanWith ("",[],[]) "|a|" <> " here.")
]
]