As said in other answers it is not possible to have subst work like that. I would define tree-map, and can call it with a function that performs the search and replace:
(tree-map tree (lambda (u) (case u (b b1) (t u))))
tree-map
I implemented something similar at some point, using two generic functions split and join, which respectively decompose a value into parts and reassemble them into a value.
(defpackage :stack-overflow (:use :cl))
(in-package :stack-overflow)
(defgeneric split (object)
(:documentation "Decompose an OBJECT into a list of parts")
(:method (o) nil)
(:method ((L list)) L))
(defgeneric join (prototype parts)
(:documentation
"Join list of PARTS according to the original PROTOTYPE object")
(:method (p (_ null)) p)
(:method ((p list) c) c))
(defun tree-map (tree function)
(declare (optimize (debug 3)))
(flet ((recurse (n) (tree-map n function)))
(let ((parts (split tree)))
(if parts
(join tree (mapcar #'recurse parts))
(funcall function tree)))))
This can also be defined as follows, if you want to call the user-provided function first on each node before splitting the result.
(defun tree-map (tree function)
(declare (optimize (debug 3)))
(flet ((tree-map (n) (tree-map n function)))
(join tree (mapcar #'tree-map (split (funcall function tree))))))
Example
I set the level of debug to 3 to keep the original source code around. This may not work on all implementations but this only for the example:
(tree-map
(function-lambda-expression #'tree-map)
(let ((cache (make-hash-table)))
(lambda (object)
(typecase object
(symbol
(or #1=(gethash object cache)
(setf #1# (gensym (string object)))))
(t object)))))
This replaces all the symbols by a fresh uninterned one, while making sure the same symbol from the input always maps to the same symbol in the output:
(#:LAMBDA843 (#:TREE844 #:FUNCTION845) (#:DECLARE846 (#:TOP-LEVEL-FORM847))
(#:DECLARE846 (#:OPTIMIZE848 (#:DEBUG849 3)))
(#:BLOCK850 #:TREE-MAP851
(#:FLET852 ((#:RECURSE853 (#:N854) (#:TREE-MAP851 #:N854 #:FUNCTION845)))
(#:LET855 ((#:PARTS856 (#:SPLIT857 #:TREE844)))
(#:IF858 #:PARTS856
(#:JOIN859 #:TREE844
(#:MAPCAR860 (#:FUNCTION845 #:RECURSE853) #:PARTS856))
(#:FUNCALL861 #:FUNCTION845 #:TREE844))))))
Simple extension
Then you can define your own methods easily:
(defstruct point x y)
(defmethod split ((p point))
(list (point-x p) (point-y p)))
(defmethod join ((p point) parts)
(destructuring-bind (x y) parts
(make-point :x x :y y)))
Subset of slots and mutation
The prototype argument is necessary to perform the dynamic dispatch on the object type, but also to recombine the parts in a meaningful way from the original object.
Suppose you have the following class, where you want only a subset of the slots to be traversable:
(defclass task ()
(name %thread %function))
(defmethod split ((o task))
(list (slot-value o 'name)))
In fact, you want to be sure that the object retains its identity no matter what function you apply, so instead of copying the object, you can just return it modified.
(defmethod join ((o task) parts)
(prog1 o
(destructuring-bind (name) parts
(setf (slot-value o 'name) name))))
Other ideas
split and join could take an additional parameter, how, to further specialize on how the split is done (allowing for different views of a same object). For example you could define a method to split a symbol into a name, a package and a property list, etc.
the use of an intermediate list of parts allocates some memory, the methods could be replaced by a single one that accepts a visitor function instead; for example, for the point:
(defmethod visit ((o point) visitor)
(make-point :x (funcall visitor (point-x o))
:y (funcall visitor (point-y o))))
Here tree-map would give #'recurse as the visitor parameter.
But split can be used separately from join to do another kind of processing than a map, so I found it more useful.
sb-mopyou could get all slot names and then search and replace them. But I don't think there is a standard way to do that. You might need to go implementation specific.