You can do this with a recursive descent parser and the syntax you've described. The big change will be to the lexer: instead of making a big list of tokens up front, it'll be pull-driven where the parser asks what the next token is, rather than moving an index through a list.
In the lexer
When the lexer finds a string literal, there are two cases:
- It's just a literal string, no interpolations. Produce a string literal token as you do now.
- It has
${ before the closing quote. Produce a special token that tells the parser that there is an interpolation following. This could be a different kind of token, or a flag on the string literal.
In either case, the lexer remembers its position as just after the last character, be it the closing quote or opening brace.
In the parser
When the parser encounters that special token, it switches to parsing an expression afterwards, as though it were parsing a brace-delimited block and ending when it sees the }. This will continue using the lexer and all the ordinary parsing mechanics, including recursing into further string literals.
When it finds the end of the interpolation it tells the lexer to start parsing a string literal again, from where it's at right now in the middle of the string (just using all the string-lexing code from after the opening quote again). The next token will either be a standard string token, meaning that's the end of the literal, or another interpolation token, meaning the process continues.
It's easiest to have the interpolation-parsing code handle all of this until the end of the string. Once it sees a regular string token, it can produce an AST node collating all the literal pieces and interpolated expressions. I've got a practical implementation of this approach here, which is relatively simple. In fact, a plain string literal is just treated as a degenerate interpolated string with no interpolations.
This isn't the only way to go about this. More bottom-up parsing models may support these sorts of construction "for free", and even within recursive-descent parsers there are other ways to manage it. It's fairly straightforward to incorporate this change on top of an existing parser, however.