If you wish to look at the sequence of tokens that forms the argument of \foo
unexpanded, you can add a trailing dot to that argument and then run a loop which removes dot-delimited arguments until removing another dot-delimited argument would yield emptiness.
Then examine whether the remaining dot-delimited argument is empty.
If so, the last token of the argument of \foo
was a dot.
If not so, the last token of the argument of \foo
was not a dot.
\documentclass{article}
\makeatletter
%%----------------------------------------------------------------------
%% Check whether argument is empty:
%%......................................................................
%% \UD@CheckWhetherNull{<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>}%
%%
%% The gist of this macro comes from Robert R. Schneck's \ifempty-macro:
%% <https://groups.google.com/forum/#!original/comp.text.tex/kuOEIQIrElc/lUg37FmhA74J>
%%
\newcommand\UD@CheckWhetherNull[1]{%
\romannumeral0\expandafter\@secondoftwo\string{\expandafter
\@secondoftwo\expandafter{\expandafter{\string#1}\expandafter
\@secondoftwo\string}\expandafter\@firstoftwo\expandafter{\expandafter
\@secondoftwo\string}\expandafter\expandafter\@firstoftwo{ }{}%
\@secondoftwo}{\expandafter\expandafter\@firstoftwo{ }{}\@firstoftwo}%
}%
%%----------------------------------------------------------------------
%% Check whether argument's last token is a dot:
%%......................................................................
%% \UD@CheckWhetherTrailingDot{<Argument which is to be checked>}%
%% {<Tokens to be delivered in case that argument
%% does have a last token which is a dot>}%
%% {<Tokens to be delivered in case that argument
%% does not have a last token which is a dot>}%
%%
\newcommand\UD@CheckWhetherTrailingDot[1]{%
\romannumeral0\UD@CheckWhetherNull{#1}%
{\@secondoftwo}%
{\UD@CheckWhetherTrailingDotGobbleToDotLoop{#1.}}%
{\expandafter\expandafter\@firstoftwo{ }{}\@firstoftwo}%
{\expandafter\expandafter\@firstoftwo{ }{}\@secondoftwo}%
}%
%%%%
\newcommand\UD@CheckWhetherTrailingDotGobbleToDotLoop[1]{%
\expandafter\UD@CheckWhetherNull
\expandafter{\UD@CheckWhetherTrailingDotGobbleToDot#1}{%
\UD@CheckWhetherTrailingDotFork{.}#1%
}{%
\expandafter\UD@CheckWhetherTrailingDotGobbleToDotLoop
\expandafter{\UD@CheckWhetherTrailingDotGobbleToDot#1}%
}%
}%
%%%%
\newcommand\UD@CheckWhetherTrailingDotGobbleToDot{}
\long\def\UD@CheckWhetherTrailingDotGobbleToDot#1.{}
%%%%
\newcommand\UD@CheckWhetherTrailingDotFork{}%
\long\def\UD@CheckWhetherTrailingDotFork#1.{%
\expandafter\UD@CheckWhetherNull\expandafter{\@gobble#1}%
}%
%%%%
\newcommand\foo[1]{%
\UD@CheckWhetherTrailingDot{#1}{#1}{#1.}%
}%
\makeatother
\begin{document}
With \verb|\foo| an empty argument is recogniced as an argument
that dose not have a trailing dot:
\verb|\foo{}|: \foo{}
\verb*|X\foo{ }X|: X\foo{ }X
\verb|\foo{.}|: \foo{.}
\verb|\foo{Bla. Bla.}|: \foo{Bla. Bla.}
\verb|\foo{Bla. Bla}|: \foo{Bla. Bla}
\verb|\foo{Bla. {Bla.}}|: \foo{Bla. {Bla.}}
\verb|\foo{hello world}|: \foo{hello world}
\verb|\foo{hello world.}|: \foo{hello world.}
With \verb|\foo| dots nested in braces will not count as trailing dots as
in this case the last token of the argument is not a dot but a closing brace:
\verb|\foo{hello world{.}}|: \foo{hello world{.}}
\end{document}

The gist of the following solution, which is much better, comes from the answer of wipet :
Choose a <sentinel token>
which must not occur within the argument to examine (unless nested in braces).
Append <sentinel token>.<sentinel token><delimiter>
(<delimiter>
other than .
and other than <sentinel token>
and other than .<sentinel token>
) behind the argument to examine before fetching a first argument delimited by .<sentinel token>
and fetching a second argument delimited by <delimiter>
.
In case the last token of the argument to examine is a dot, that dot and the first <sentinel token>
from the appended sequence form the delimiter for that first argument to fetch, and the second argument is not empty but holds the sequence .<sentinel token>
.
In case the last token of the argument to examine is not a dot, the dot from the appended sequence and the second <sentinel token>
from the appended sequence form the delimiter for that first argument to fetch, and the second argument is empty.
\documentclass{article}
\makeatletter
\newcommand\Wipet@CheckWhetherTrailingDot[1]{%
\Wipet@CheckWhetherTrailingDotA#1\end.\end!%
}%
\newcommand\Wipet@CheckWhetherTrailingDotA{}%
\long\def\Wipet@CheckWhetherTrailingDotA#1.\end#2!{%
\ifx!#2!\expandafter\@secondoftwo\else\expandafter\@firstoftwo\fi
}%
\newcommand\foo[1]{%
\Wipet@CheckWhetherTrailingDot{#1}{#1}{#1.}%
}%
\makeatother
\begin{document}
With \verb|\foo| an empty argument is recogniced as an argument
that dose not have a trailing dot:
\verb|\foo{}|: \foo{}
\verb*|X\foo{ }X|: X\foo{ }X
\verb|\foo{.}|: \foo{.}
\verb|\foo{Bla. Bla.}|: \foo{Bla. Bla.}
\verb|\foo{Bla. Bla}|: \foo{Bla. Bla}
\verb|\foo{Bla. {Bla.}}|: \foo{Bla. {Bla.}}
\verb|\foo{hello world}|: \foo{hello world}
\verb|\foo{hello world.}|: \foo{hello world.}
With \verb|\foo| dots nested in braces will not count as trailing dots as
in this case the last token of the argument is not a dot but a closing brace:
\verb|\foo{hello world{.}}|: \foo{hello world{.}}
\end{document}

What's embarrassing for me is that when writing my first solution, which is cumbersome, I did not remember the method shown by wipet although it is well-known.
E.g., for several years in the early nineties the late Michael Downes presented a series of TeX macro programming challenges he called Around the Bend.
More information about this can be found on CTAN at https://ctan.org/pkg/around-the-bend.
The document holding all programming challenges and answers can be found at
https://ctan.org/tex-archive/info/challenges/AroBend/AroundTheBend.pdf .
In Challenge 15 – Space removal, section 15.2.3 – Some remarks about the domain of the problem, on page 66, the method shown by wipet is exhibited and explained. The only difference is that there the method is used for trimming a trailing space instead of trimming a trailing dot:
In the previous posting I discussed the method of removing a trailing
space by scanning for a token pair <space><bizarre>
[...]