Handling Simple @-Mention Lookups (in Swift) with Regex

The power of Swift.String and NSRegularExpression

Travis Bogosian
Frame.io Engineering
7 min readJan 5, 2021

--

Photo by Amador Loureiro on Unsplash

In the Frame.io mobile app, we’ve built an internal framework for handling at-mention lookups. When our users input a character (in this case, an “@“) into a comment field, they will subsequently see a list of users to tag in their comment (for more on that framework, see my teammate’s article on building a cross-platform @-mention framework). In this article, I’ll dive into one component of that framework, the StringTriggerReporter, which is solely responsible for looking at strings, and reporting back if it has found a matching trigger. This will include setting up a single-responsibility object, and a dive into using Regex in Swift using NSRegularExpression.

First, let’s look at what the requirements are for this problem. The component needs to know which triggers (e.g. @, #, :) to listen for, receive string input, and report when it has found a match with the text trailing the trigger. For example, if we set up this component to listen for “@“s, we would want the inputs to report the following outputs:

  • “Hello world” → Nothing
  • “Hello @T” → Lookup triggered with “@T”
  • “Hell @Travis B” → Lookup triggered with “@Travis B”
  • “Hello Tr@vis” -> Nothing

As we can see above, there are some edge cases that we’ll need to watch out for. Fortunately, Regex and Swift.String have a number of nice conveniences for handling such edge cases. First, let’s lay out some skeleton code to describe what we want to have happen.

The core of this component is pretty simple. Let’s break it down:

  1. We set up a way to delegate out what this component finds. This is in no way the only way this can be done. Variations on this might include passing in a callback closure, having the check function return results directly, or using an observer pattern. But in our case we opted for delegation, because it fit nicely into the structure of our framework.
  2. We initialize the component with the triggers we want to listen for. In our framework, we listen for ["@", “@”], because we want to be sure we catch all input versions of “@“. Notably, we could pass in [“@“, “#”, “:”] and have this component report multiple triggers for things like hashtag completions, user mentions, and emoji completions. This means that our delegation should make sure to include the value of the trigger in its response. If we were focused on more trigger tokens, we might return which trigger caused our delegation, rather than returning the string with the token in it, like “@travis”.
  3. This is where the core of this class’s logic will appear. This function is where we’ve said we’re going to “check and report a trigger” from a string, and that we need the current location of the cursor as well. If we were just passing in the String alone, without the cursor, we would be making a lot of assumptions; for example, in the case where I was typing “Hello @trevor @travis and @tyler”, it would be very difficult to figure out which bit of the string I should be parsing!

Now that we have a structure to build on, we can get to work pulling data out of the string. This is where Regex and Swift.String come in. First, we need a good regex to read the string and find matches for our trigger, plus characters that follow it, optionally including a space and last name. This might not be so simple. The full rule that we’re going to write out is basically “match any time we see an “@“, following either a space character or the start of a line, with 0 or more characters after it, and an optional space with 0 or more characters after it”. Well, let’s break that down:

  • “@“ is just @ in regex
  • A space character can be represented as \s
  • The beginning of the line is ^
  • The option of either a space character or the beginning of the line is (\s|^)
  • A number of word characters 0 or greater is \w* (we could use [a-zA-Z]* if we just wanted letters, but the \w will include numbers and underscores as well)
  • And an optional group of characters after a space is ( \w*)?

Not bad! If we add all that together we end up with the regex: (\s|^)@\w*( \w*)?. Of course, in swift, we’ll want to escape this our \s, so we’ll want our regex string to look like (\\s|^)@\\w*( \\w*)?. And if we want to make this more flexible in our code based on a trigger input, we can inject it like (\\s|^)\(triggerString)\\w*( \\w*)?. This may not look especially readable, but we’ve just saved ourselves a lot of parsing and edge cases by using a regular expression.

Now we have something that can take a string and “capture” matches. But we still need to get the regex string to work in our Swift codebase. That’s where NSRegularExpression comes in. In order to use this pattern, we’ll want to pass it into NSRegularExpression(pattern: mentionsRegexPattern, options: .caseInsensitive). In our case, we opted to generate all of our regular expressions up front in the initializer, but again, this is a place where there’s flexibility about the timing of generating this NSRegularExpression object. So, how do we actually apply this to our string? It looks a bit like this:

The quick breakdown is

  1. We only really want to search up until the location that we passed in. In our case we’re ignoring any text after the user’s cursor, which is a pattern that many apps use for this sort of @-mention flow.
  2. We’ve covered this already! Back up a couple paragraphs if you missed it…
  3. Get all of the instances of our regex pattern found in the text within the given range
  4. Convert our matches to the relative ranges within the text, and then find the range that actually contains the user’s cursor.
  5. return the substring (more on the joys of substrings in a moment)

Now, the one piece that’s not really explained above is the toSearchRange in part 4. It may not be immediately obvious how the return type of matches(in:options:range:), NSTextCheckingResult, converts to a range. It’s actually an important note, and the solution changes how we should go about setting up our regex. If you play around with the above regex, either using NSRegularExpression, or from one of the many free regex exploration tools online, you may notice that there tend to be multiple patterns that are found. This is because I was sloppy above, and defined some of the pieces as capture groups, defined like (<captured things>). If you look back you’ll see that we’ve captured (\s|^) and ( \w*)?! So our captured matches are actually going to be the combination of all of the captured groups… the whitespace at the beginning of our string, the optional last name, and what we were looking for: a space, followed by an @, followed by some text. There are two things we can do here. First, we can update our regex to ignore those groups, by using “(?: <non captured things>)”. Now our regex is (?:\\s|^)(trigger)\\w*(?: \\w*)?, and we can pull the result out of our NSTextCheckingResult by using result.range(at: 0) This range is the range for the full captured string, including the opening space we used to indicate the beginning of our trigger.

But wouldn’t it be nice if we didn’t need to use some arbitrary index, and if we didn’t have to capture that space? Well, there’s one more regex trick we can use… named capture groups! A feature that has been supported by NSRegularExpression since iOS 11.0. We’ll make our regex a little more complicated just one more time.

First, we declare an arbitrary static name for our group, for simplicity let’s call it “search”. So

Then we can explicitly capture what we want, let we implicitly did before.
(?:\\s|^)(?<\(CaptureGroupName.search)>(trigger)\\w*(?: \\w*)?))
All this means is that we’re now explicitly wrapping all of the content in the “@…” in a capture group (ignoring the space that helped us trigger in the first place), and naming the captured text “search”. With this in place we can make use of NSTextCheckingResult’s range(withName:) function, and pass in our capture group name to pull out exactly the range we need. Besides the incredibly ugly-looking regex, it’s pretty smooth!

Now we can pull out the right range at the right time (or report that we don’t have any trigger strings, if we need to do something like stop showing a menu when triggers end), and grab a Substring to send to our delegate. As I mentioned, the fact that we get a Substring in particular is a wonderful thing. It allows us to reference the string in relation to the super string (along with a few other benefits that I won’t dig into here), which can be a very powerful tool. This means that the delegate not only gets the resulting trigger text, it can determine the trigger from the front of the Substring if it needs, and it can use this Substring’s range (.range) to update highlighting in our UI. This is where Swift’s String architecture really shines.

I won’t dig too much deeper into the other regex options or String/Substring options that you can use when building your own string trigger logic, but there’s lots more to discover in there. Hopefully this little dive into using regex and String to add @-mentioning behavior was useful in helping implement your own triggers, or in getting a little better acquainted with regex, especially in the context of Swift. If you’re still craving more Swift String-related goodness, I highly recommend getting Flight School’s Guide to Swift Strings, a wonderful and in-depth book on the ins and outs of Swift String-ing.

--

--