The reason why your routine does not even replace s by S and i by I is in the lines:
\ifx#1s\zCapSwitch1\fi
\ifx#1i\zCapSwitch1\fi
In case TeX gathers a number digit-character-token by digit-character-token, it will keep expanding tokens. \fi is expandable, thus TeX does not stop gathering digits when having encountered \fi.
Thus TeX is still gathering digits of the number of the \zCapSwitch-assignment when carrying out the subsequent \ifnum-comparison.
Thus the new value is not yet assigned to \zCapSwitch when the subsequent \ifnum-comparison is carried out.
Just make sure to terminate the \zCapSwitch-assignment by having the digit-sequence that forms the number trailed by a space or by \relax:
\ifx#1s\zCapSwitch1 \fi
\ifx#1i\zCapSwitch1 \fi
TeX will take the spaces behind 1 for something that terminates the digit-sequence and therefore will discard them instead of keeping them and producing horizontal glue.
When you do that, the code looks almost the same, but replacing s and i works to some extent:
\documentclass[margin=5mm,varwidth]{standalone}
\begin{document}
\newcount\zCapSwitch % UPPERCASE SWITCH
\zCapSwitch0 % SET TO FALSE (NO UPPERCASE CONVERSION NEEDED)
\def\zEnd{\zEnd}
\def\zzIterator#1{%
\ifx#1\zEnd
\else
% ------------ %
% OUTPUT CHUNK %
% ------------ %
% CAPITALIZE "s" AND "i"
%
% YOU NEED SPACES TO TERMINATE DIGIT-SEQUENCES.
%
\ifx#1s\zCapSwitch1 \fi
\ifx#1i\zCapSwitch1 \fi
\ifnum\zCapSwitch=1
\uppercase{#1}%
\zCapSwitch0 %
\else
#1%
\fi
% ------------ %
\expandafter\zzIterator
\fi
}
\def\zIterator#1{%
\zzIterator#1\zEnd
}
\zIterator{Keep spaces intact!}
\end{document}

But spaces are still not preserved.
The reason is that \zzIterator does process a non-delimited macro-argument.
While gathering the tokens that belong to a non-delimited macro-argument, TeX always drops/removes/discards explicit space-tokens that precede the non-delimited macro-argument.
Besides this the case of the argument of \zIterator containing brace-groups is not handled.
Here is an approach where \futurelet is used for "looking ahead" at the meaning of the next token and \afterassignment-\let is used for removing tokens whose meaning equals the meaning of the space-token.
This approach sort of preserves spaces.
But it does still not handle the case of the argument of \zIterator containing brace-groups:
\documentclass[margin=5mm,varwidth]{standalone}
\begin{document}
\newcount\zCapSwitch % UPPERCASE SWITCH
\zCapSwitch0 % SET TO FALSE (NO UPPERCASE CONVERSION NEEDED)
\def\zEnd{\zEnd}%
\long\def\foo#1{#1}%
\long\def\fot#1#2{#1}%
\long\def\sot#1#2{#2}%
\foo{\let\zzSpace= } %
\def\zzIterator{\futurelet\zzNext\zzSpacefork}%
\def\zzSpacefork{%
\ifx\zzNext\zzSpace
\expandafter\fot
\else
\expandafter\sot
\fi
{ \afterassignment\zzIterator\let\zzNext= }%
{\zzIteratorA}%
}%
\def\zzIteratorA#1{%
\ifx#1\zEnd
\else
% ------------ %
% OUTPUT CHUNK %
% ------------ %
% CAPITALIZE "s" AND "i"
%
% YOU NEED SPACES TO TERMINATE DIGIT-SEQUENCES.
%
\ifx#1s\zCapSwitch1 \fi
\ifx#1i\zCapSwitch1 \fi
\ifnum\zCapSwitch=1
\uppercase{#1}%
\zCapSwitch0 %
\else
#1%
\fi
% ------------ %
\expandafter\zzIterator
\fi
}
\def\zIterator#1{%
\zzIterator#1\zEnd
}
\zIterator{Keep spaces intact!}
\end{document}

Handling brace-groups is an interesting subject because having TeX "look ahead" at the meaning of the next token via \futurelet or \let actually is not sufficient:
This way you can find out whether the meaning of the next token equals, e.g., the meaning of the character-token {1, i.e., the meaning of that character-token whose character-code is 123—123 is the number of the code-point of the {-character in the TeX-engine's internal character representation scheme—and whose category-code is 1(begin group). But you cannot find out whether that next token is explicit, i.e., whether that next token is the explicit {1(begin group)-token, or is implicit, i.e., is something like the \bgroupcontrol word-token, with \let\bgroup={.
"Explicitness/implicitness" of character-tokens of category-code 1(begin group) respectively category-code 2(end group) does matter because non-delimited macro-arguments being empty or having a leading explicit space-token or consisting of more than one token and delimited macro-arguments containing the argument-delimiter are to be nested into a pair of explicit character-tokens of category-code 1(begin group) respectively category-code 2(end group) while implicit character-tokens of category-code 1(begin group) respectively category-code 2(end group) will by TeX not be taken for markers for the begin or the end of a macro-argument.
Besides this: Some day somebody might—in addition to the character { respectively }—assign category-code 1(begin group) respectively category-code 2(end group) to some other characters, which then will, e.g., in matters of beginning/ending a local scope and in matters of gathering macro-arguments and in matters of gathering ⟨balanced text⟩, be treated by TeX like { respectively }, but whose meaning will not equal the meaning of {1(begin group) respectively }2(end group) as the character-codes are different.
I can offer a \romannumeral0-expansion-based tail-recursive routine
for replacing the explicit catcode-11(letter)-character-tokens s
and i by explicit catcode-11(letter)-character-tokens S respectively I where
- the result is delivered after two "hits" by
\expandafter.
- no temporay assignments and the like take place. E.g., things like
\afterassignment/\let/\futurelet are not used. Therefore the routine can also be used in expansion-contexts, e.g., within \csname..\endcsname.
- (unmatched)
\if.. /\else/\fi in the argument do not disturb the routine as the routine is based on delimited arguments and therefore does not use \if..-tests at all.
- although the token
\UDSelDOm is used as "sentinel-token" in some places, that token can occur within the argument, thus there are no forbidden tokens for the argument. (Except that you generally cannot use \outer tokens in macro-arguments.)
- the mechanism can cope with things being nested in curly braces.
- you don't need to think about the question of distinguishing explicit brace- and/or space-tokens from their implicit pendants. (When "looking ahead" at the next token via
\let or \futurelet, this question probably might cause headache because \let and \futurelet let you cope just with the meanings of tokens while the meaning of a character-token does not include information regarding whether that character-token is an explicit or an implicit character-token...)
A side-effect of the routine is that it replaces
- all explicit-catcode 1(begin group)-character-tokens by explicit curly-opening-brace-character-tokens (
{) of catcode 1(begin group).
- all explicit-catcode 2(end group)-character-tokens by explicit curly-closing-brace-character-tokens (
}) of catcode 2(end group).
Usually { is the only character whose catcode is 1(begin group).
Usually } is the only character whose catcode is 2(end group).
Therefore this usually should not be a problem.
The routine is just an example. You can enhance it to replace characters other than s and i by modifying the macros \UDsiSelect and \UDsiFork. If you do that, you also need to adjust the call to \UDsiFork within \zIteratorTailRecursiveLoop.
(This is a bunch of code. If you were not insisting in plain TeX, things could be shortened, e.g., using expl3.)
%%
%% As you wished plain TeX, don't use latex/pdflatex but use tex/pdftex for compiling.
%%
%%=============================================================================
%% Paraphernalia:
%% \UDfirstoftwo, \UDsecondoftwo,
%% \UDExchange, \UDPassFirstBehindThirdToSecond
%% \UDremovespace, \UDCheckWhetherNull, \UCheckWhetherBrace,
%% \UDCheckWhetherLeadingSpace, \UDExtractFirstArg
%%=============================================================================
\long\def\UDfirstoftwo#1#2{#1}%
\long\def\UDsecondoftwo#1#2{#2}%
\long\def\UDExchange#1#2{#2#1}%
\long\def\UDPassFirstBehindThirdToSecond#1#2#3{#2{#3}{#1}}%
\UDfirstoftwo{\def\UDremovespace}{} {}%
%%=============================================================================
%% Check whether argument is empty:
%%=============================================================================
%% \UDCheckWhetherNull{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked is empty>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked is not empty>}%
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
%% The gist of this macro comes from Robert R. Schneck's \ifempty-macro:
%% <https://groups.google.com/forum/#!original/comp.text.tex/kuOEIQIrElc/lUg37FmhA74J>
%%
\long\def\UDCheckWhetherNull#1{%
\romannumeral0\expandafter\UDsecondoftwo\string{\expandafter
\UDsecondoftwo\expandafter{\expandafter{\string#1}\expandafter
\UDsecondoftwo\string}\expandafter\UDfirstoftwo\expandafter{\expandafter
\UDsecondoftwo\string}\UDfirstoftwo\expandafter{} \UDsecondoftwo}%
{\UDfirstoftwo\expandafter{} \UDfirstoftwo}%
}%
%%=============================================================================
%% Check whether argument's first token is a catcode-1-character
%%=============================================================================
%% \UDCheckWhetherBrace{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked has leading
%% catcode-1-token>}%
%% {<Tokens to be delivered in case that argument
%% which is to be checked has no leading
%% catcode-1-token>}%
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
\long\def\UDCheckWhetherBrace#1{%
\romannumeral0\expandafter\UDsecondoftwo\expandafter{\expandafter{%
\string#1.}\expandafter\UDfirstoftwo\expandafter{\expandafter
\UDsecondoftwo\string}\UDfirstoftwo\expandafter{} \UDfirstoftwo}%
{\UDfirstoftwo\expandafter{} \UDsecondoftwo}%
}%
%%=============================================================================
%% Check whether brace-balanced argument's first token is an explicit
%% space token
%%=============================================================================
%% \UDCheckWhetherLeadingSpace{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case <argument
%% which is to be checked>'s 1st token is a
%% space-token>}%
%% {<Tokens to be delivered in case <argument
%% which is to be checked>'s 1st token is not
%% a space-token>}%
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
\long\def\UDCheckWhetherLeadingSpace#1{%
\romannumeral0\UDCheckWhetherNull{#1}%
{\UDfirstoftwo\expandafter{} \UDsecondoftwo}%
{\expandafter\UDsecondoftwo\string{\UDInnerCheckWhetherLeadingSpace.#1 }{}}%
}%
\long\def\UDInnerCheckWhetherLeadingSpace#1 {%
\expandafter\UDCheckWhetherNull\expandafter{\UDsecondoftwo#1{}}%
{\UDExchange{\UDfirstoftwo}}{\UDExchange{\UDsecondoftwo}}%
{\UDExchange{ }{\expandafter\expandafter\expandafter\expandafter
\expandafter\expandafter\expandafter}\expandafter\expandafter
\expandafter}\expandafter\UDsecondoftwo\expandafter{\string}%
}%
%%=============================================================================
%% Extract first inner undelimited argument:
%%=============================================================================
%% \UDExtractFirstArg{ABCDE} yields {A}
%% \UDExtractFirstArg{{AB}CDE} yields {AB}
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
\long\def\UDRemoveTillUDSelDOm#1#2\UDSelDOm{{#1}}%
\long\def\UDExtractFirstArg#1{%
\romannumeral0%
\UDExtractFirstArgLoop{#1\UDSelDOm}%
}%
\long\def\UDExtractFirstArgLoop#1{%
\expandafter\UDCheckWhetherNull\expandafter{\UDfirstoftwo{}#1}%
{ #1}%
{\expandafter\UDExtractFirstArgLoop\expandafter{\UDRemoveTillUDSelDOm#1}}%
}%
%%=============================================================================
%% Extract K-th inner undelimited argument:
%%=============================================================================
%% \UDExtractKthArg{<integer K>}{<list of undelimited args>}
%%
%% In case there is no K-th argument in <list of indelimited args> :
%% Does not deliver any token.
%% In case there is a K-th argument in <list of indelimited args> :
%% Does deliver that K-th argument with one level of braces removed.
%%
%% Examples:
%%
%% \UDExtractKthArg{0}{ABCDE} yields: <nothing>
%%
%% \UDExtractKthArg{3}{ABCDE} yields: C
%%
%% \UDExtractKthArg{3}{AB{CD}E} yields: CD
%%
%% \UDExtractKthArg{4}{{001}{002}{003}{004}{005}} yields: 004
%%
%% \UDExtractKthArg{6}{{001}{002}{003}} yields: <nothing>
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
\long\def\UDExtractKthArg#1{%
\romannumeral0%
% #1: <integer number K>
\expandafter\UDExtractKthArgCheck
\expandafter{\romannumeral\number\number#1 000}%
}%
\long\def\UDExtractKthArgCheck#1#2{%
\UDCheckWhetherNull{#1}{ }{%
\expandafter\UDExtractKthArgLoop\expandafter{\UDfirstoftwo{}#1}{#2}%
}%
}%
\long\def\UDExtractKthArgLoop#1#2{%
\expandafter\UDCheckWhetherNull\expandafter{\UDfirstoftwo#2{}.}{ }{%
\UDCheckWhetherNull{#1}{%
\expandafter\UDExchange
\romannumeral0\UDExtractFirstArgLoop{#2\UDSelDOm}{ }%
}{%
\expandafter\UDExchange\expandafter{\expandafter{\UDfirstoftwo{}#2}}%
{\expandafter\UDExtractKthArgLoop\expandafter{\UDfirstoftwo{}#1}}%
}%
}%
}%
%%=============================================================================
%% Fork whether argument either is an _explicit_
%% catcode 11(letter)-character-token of the set {s, i}
%% or is something else.
%%=============================================================================
%% \UDsiFork{<Argument to check>}{%
%% {<tokens to deliver in case <Argument to check> is s>}%
%% {<tokens to deliver in case <Argument to check> is i>}%
%% {<tokens to deliver in case <Argument to check> is empty or something else>}%
%% }%
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
\long\def\UDGobbleToExclam#1!{}%
\long\def\UDCheckWhetherNoExclam#1{%
\expandafter\UDCheckWhetherNull\expandafter{\UDGobbleToExclam#1!}%
}%
\long\def\UDsiSelect#1!!s!i!#2#3!!!!{#2}%
\long\def\UDsiFork#1#2{%
\romannumeral
\UDCheckWhetherNoExclam{#1}{%
\UDsiSelect
!#1!s1!i!{\expandafter\UDsecondoftwo\UDExtractKthArg{3}{#2}}% empty
!!#1!i!{\expandafter\UDsecondoftwo\UDExtractKthArg{1}{#2}}% s
!!s!#1!{\expandafter\UDsecondoftwo\UDExtractKthArg{2}{#2}}% i
!!s!i!{\expandafter\UDsecondoftwo\UDExtractKthArg{3}{#2}}% something else without !
!!!!%
}{\expandafter\UDsecondoftwo\UDExtractKthArg{3}{#2}}% something else with !
}%
%%=============================================================================
%% The main routine which calls the main loop:
%%=============================================================================
%% \zIterator{<Argument where s respectively s to be replaced by S respectively I>}
%%
%% Due to \romannumeral0-expansion the result is delivered after two
%% expansion-steps/after two "hits" by \expandafter.
%%
\long\def\zIterator{%
\romannumeral0\zIteratorTailRecursiveLoop{}%
}%
%%=============================================================================
%% The main loop:
%%=============================================================================
%% \zIteratorTailRecursiveLoop{<list of tokens where replacement
%% is already done>}%
%% {<remaining list of tokens where replacement of
%% s/i by S/I must still be performed>}%
%%
%% In case the <remaining list of tokens where replacement of s/i by S/I must
%% still be performed> is empty, you are done, thus deliver the <list of tokens
%% where replacement is already done>.
%% Otherwise:
%% Check if the <remaining list of tokens where replacement of s/i
%% by S/I must still be performed> has a leading space.
%% If so: Add a space-token to the <list of tokens where replacement is
%% already done>.
%% Remove the leading space token from the <remaining list of tokens
%% where replacement of s/i by S/I must still be performed>
%% Otherwise:
%% Check if the <remaining list of tokens where replacement of s/i
%% by S/I must still be performed> has a leading brace.
%% If so: Extract its first component/its first undelimited argument
%% and apply this routine to that extraction and add the
%% result (nested in braces) to the <list of tokens where
%% replacement is already done> .
%% Otherwise:
%% Check if the <remaining list of tokens where replacement
%% of s/i by S/I must still be performed>'s first component
%% is s or i.
%% If so: add "S" respectively "I" to the <list of tokens
%% where replacement is already done> .
%% Otherwise:
%% Add the <remaining list of tokens where replacement
%% of s/i by S/I must still be performed>'s first
%% component to the <list of tokens where replacement
%% is already done> .
%% Remove the first compoinent/the first undelimited argument from
%% the <remaining list of tokens where replacement of s/i by S/I
%% must still be performed>.
\long\def\zIteratorTailRecursiveLoop#1#2{%
% #1 - list of tokens where replacement is already done
% #2 - remaining list of tokens where replacement of s/i by S/I must
% still be performed
\UDCheckWhetherNull{#2}{ #1}{%
\UDCheckWhetherLeadingSpace{#2}{%
\expandafter\UDPassFirstBehindThirdToSecond\expandafter{%
\UDremovespace#2%
}{%
\UDPassFirstBehindThirdToSecond{#1 }{\UDsecondoftwo{}}%
}%
}{%
\expandafter\UDPassFirstBehindThirdToSecond\expandafter{%
\UDfirstoftwo{}#2%
}{%
\UDCheckWhetherBrace{#2}{%
\expandafter\UDPassFirstBehindThirdToSecond\expandafter{%
\romannumeral0\expandafter
\UDExchange\expandafter{\expandafter{%
\romannumeral0\expandafter\zIteratorTailRecursiveLoop
\expandafter{\expandafter}%
\romannumeral0\UDExtractFirstArgLoop{#2\UDSelDOm}%
}}{ #1}%
}{\UDsecondoftwo{}}%
}{%
\expandafter\UDsiFork
\romannumeral0\UDExtractFirstArgLoop{#2\UDSelDOm}{%
{\UDPassFirstBehindThirdToSecond{#1S}{\UDsecondoftwo{}}}%
{\UDPassFirstBehindThirdToSecond{#1I}{\UDsecondoftwo{}}}%
{%
\expandafter\UDPassFirstBehindThirdToSecond\expandafter{%
\romannumeral0\expandafter\UDExchange
\romannumeral0\UDExtractFirstArgLoop{#2\UDSelDOm}{ #1}%
}{\UDsecondoftwo{}}%
}%
}%
}%
}%
}%
{\zIteratorTailRecursiveLoop}%
}%
}%
%%=============================================================================
%% Usage-Examples of \zIterator which show that
%% - the result is delivered after two "hits" by \expandafter
%% - the mechanism is based on expansion only. No temporay assignments take
%% place. Therefore \zIterator can also be used in expansion-contexts,
%% e.g., within \csname..\endcsname,
%% - (unmatched) \if.. /\else/\fi in the argument do not disturb the mechanism.
%% - although the token \UDSelDOm is used as "sentinel-token" in some places,
%% that token can occur within the argument, thus there are no forbidden
%% tokens. (Except that you generally cannot use \outer tokens in
%% macro-arguments.)
%% - the mechanism can cope with things being nested in curly braces.
%%=============================================================================
\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\test
\expandafter\expandafter\expandafter{%
\zIterator{A \TeX \is {\funny } {s sssi}i i \else \UDSelDOm {\fi } do ## not disturb me.}%
}
{\tt\meaning\test}%
\def\aSSbISSIIIISz{Yeah, that's it!}
\csname\zIterator{assbissiiiisz}\endcsname
\bye
\let\zTest=ain the first place and what all this would be used for. – egreg Nov 18 '19 at 11:07tokcyclepackage (see this answer) is a very simple solution. – user202729 Nov 09 '21 at 06:43