Skip to content

Instantly share code, notes, and snippets.

@pete-murphy
Last active February 20, 2019 17:07
Show Gist options
  • Save pete-murphy/e1ac59fa688fbc2f971ce5cab64669f5 to your computer and use it in GitHub Desktop.
Save pete-murphy/e1ac59fa688fbc2f971ce5cab64669f5 to your computer and use it in GitHub Desktop.
Highlight Matches

Took inspiration from this Wes Bos tutorial: https://youtu.be/y4gZMJKAeWs?t=868 (I fast forwarded to relevant part)

However, he's doing a simple .replace, which works for strings, but we want to return JSX! So his solution doesn't work in our case (at least all the methods I've tried; I found this SO thread from createClass days of React that addresses why this doesn't work and gives a possible solution: https://stackoverflow.com/questions/30474506/replace-part-of-string-with-tag-in-jsx).

Constructing a RegExp

To start, we need a function that constructs a regular expression from some string input. At first I naively thought I could do

const toRegExp = str => new RegExp(str, "gi")

This breaks though, for example when str is [ you'll get an invalid regex. We need to escape the special characters, and for this I copy-pasted from Stack Overflow 🤫

const toRegExp = str =>
  new RegExp(str.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"), "gi")

Now I want a function that takes a string and splits it up into parts that match the regular expression and parts that don't. As noted, .replace almost works here but you can only return a string from it. So

"Garage".replace(/g/ig, match => `<mark>${match}</mark>`)

will literally just give you the string "<mark>G</mark>ara<mark>g</mark>e", not the JSX that we want.

Playing around with String and RegExp methods, I found something that seemed to work: any time you run .split and .match on a string with the same regular expression, you'll get two arrays with which you can piece together the original string.

const toMissesAndMatches = str => query => {
  const misses = str.split(query)
  const matches = str.match(query)
  return { misses, matches }
}

It seems like you can always reconstruct the original string by interweaving misses and matches

toMissesAndMatches("Garage")(toRegExp("ga"))
//-> { misses: [ '', 'rage' ], matches: [ 'Ga' ] }

toMissesAndMatches("Elephant")(toRegExp("leph"))
//-> { misses: [ 'E', 'ant' ], matches: [ 'leph' ] }

toMissesAndMatches("Hello!")(toRegExp("hello!"))
//-> { misses: [ '', '' ], matches: [ 'Hello!' ] }

But this is still just conjecture at this point, there's no law that I know of that asserts this to be true, so how do we verify?

Testing our hypothesis

Let's make a reconstructFromMissesAndMatches function and test it. For that, I'm relying on a function that's common in Haskell for "zipping together" two collections, appropriately called zipWith. Here's one way we could implement our own

const zipWith = fn => ([x, ...xs]) => ([y, ...ys], acc = []) =>
  x === undefined ? acc : zipWith(fn)(xs)(ys, [...acc, fn(x)(y)])

zipWith(a => b => a + b)([0, 1, 2])([1, 2, 3])
//-> [1, 3, 5]

👆 That admittedly looks pretty weird, but luckily zipWith is also implemented in utility libraries like lodash, ramda, etc.

import zipWith from "lodash/fp/zipWith"

const reconstructFromMissesAndMatches = str => query => {
  const misses = str.split(query)
  const matches = str.match(query)
  return zipWith((miss, match) => [miss, match], misses, matches)
    .flat()
    .join("")
}

reconstructFromMissesAndMatches("Hello there how are you?")(toRegExp("h"))
//-> "Hello there how are you?"

Seems to work! But to be extra sure this doesn't fail on some edge case we're overlooking, let's really put it through the wringer using jsverify.

import { reconstructFromMissesAndMatches, toRegExp } from "."
import { property, string } from "jsverify"

describe("reconstructFromMissesAndMatches", () => {
  property(
    "equality for arbitrary string values",
    string,
    string,
    (str, q) => reconstructFromMissesAndMatches(str)(toRegExp(q)) === str
  )
})

This generates 100 random tests, using arbitrary string values and testing edge cases first.

Final result

Then this is the final highlightMatches function, which has the pseudo-Hindley-Milner signature String -> RegExp -> [JSX]

const highlightMatches = str => query => {
  const misses = str.split(query)
  const matches = str.match(query)
  return zipWith((x, y) => [x, <mark>{y}</mark>], misses, matches).flat()
}

(Hopefully) working example found here: https://codesandbox.io/s/github/ptrfrncsmrph/React-Todo/tree/peter-murphy


(Array.prototype.flat() is implemented in most browsers so hopefully this works out, but ideally should be poly-filled)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment