Perform localized, evaluation-leak free replacements

Here's a way making use of Block to cause f to be inert:

Block[{f},
 SetAttributes[f, HoldAllComplete];
 expr /. f[args__] :>
   RuleCondition[f[args] /. Print -> Echo]
 ]

Hold[{f[{1, {Echo[1]}}], g[{{{Print[1]}}}]}]

Note of course that this only works if you have a pattern of the form _Symbol[...]


[Edit: For most situations, @Kuba's answer is better]

I can think of one (ugly) way to do it:

Attributes[myHold] = {HoldAll};

expr /.
 f[args__] :> With[
   {res = myHold[args] /. Print -> Echo},
   f @@ res /; True
 ] /.
  HoldPattern[f_ @@ myHold[args__]] :> f[args]
(* Hold[{f[{1, {Echo[1]}}], g[{{{Print[1]}}}]}] *)

The idea is to wrap the contents of f inside a function with HoldAll attribute (not Hold, to be able to identify it uniquely later on). In a first step, the expression is returned with myHold[…] still in place. In a second round of replacements, myHold is stripped out again.


Alternatively:

expr /. 
  foo_f :> RuleCondition[Hold[foo] /. Print -> Echo] /. 
  Hold[foo_f] :> foo 

We can safely perform the second replacement because we just wrapped every f[..] with Hold.