javascripthtmlregexreplace

Count actual replacements


Overview

I've created a basic text editor with a "Replace all" feature. However, I'm facing an issue where the output indicates replacements were made even when the text remains unchanged. Here's a simplified version of my code:

const textarea = document.getElementById('textarea');
const searchInput = document.getElementById('search');
const replaceInput = document.getElementById('replace');
const output = document.getElementById('output');
const replaceButton = document.getElementById('replaceAll');

replaceButton.addEventListener('click', () => {
  const searchValue = searchInput.value;
  const replaceValue = replaceInput.value;

  if (!searchValue) {
    output.textContent = 'No search term';
    return;
  }

  const regex = new RegExp(searchValue, 'g');
  const matches = textarea.value.match(regex);

  if (matches) {
    const count = matches.length;
    textarea.value = textarea.value.replace(regex, replaceValue);
    output.textContent = `${count} replacement${count === 1 ? '' : 's'}`;
  } else {
    output.textContent = 'No matches';
  }
});
textarea {
  width: 100%;
  height: 100px;
}

#output {
  color: #666;
}
<textarea id="textarea">apple orange apple orange</textarea>
<input type="text" id="search" placeholder="Search">
<input type="text" id="replace" placeholder="Replace">
<button id="replaceAll">Replace all</button>
<div id="output"></div>

Edge cases

Non-regex search

Regex search

Question

How can I modify the code to correctly handle these edge cases and display "No replacement" when the text remains unchanged after the replacement operation?


Solution

  • One way to do this is using a replacer function instead of a replacement template:

    const replaceAndCount = (str, pattern, replacer) => {
      let changes = 0;
    
      const result = str.replace(pattern, (...args) => {
        const replacement = replacer(...args);
        
        if (replacement !== args[0]) {
          changes++;
        }
        
        return replacement;
      });
      
      return {result, changes};
    };
    
    console.log(replaceAndCount("Hello", /[^aeiou]/gi, letter => letter.toUpperCase()));

    But then you have to convert replacement templates to replacer functions. For example, here’s an (untested) implementation that’s compatible with the JavaScript default:

    const toReplacer = template => {
      const namedGroupParts = template.split(/(\$(?:[$&`']|\d{1,2}|<[^>]*>))/);
      const noNamedGroupParts = template.split(/(\$(?:[$&`']|\d{1,2}))/);
      
      return (...args) => {
        let string = args.pop();
        let groups = undefined;
        
        // A final `groups` argument may or may not be present, depending on whether the regex has named groups.
        if (typeof string === 'object') {
          groups = string;
          string = args.pop();
        }
        
        const index = args.pop();
        
        const parts = groups != null ? namedGroupParts : noNamedGroupParts;
        let result = parts[0];
        
        for (let i = 1; i < parts.length; i += 2) {
          switch (parts[i].charAt(1)) {
            case '$':
              result += '$';
              break;
    
            case '&':
              result += args[0];
              break;
              
            case '`':
              result += string.substring(0, index);
              break;
              
            case "'":
              result += string.substring(index + args[0].length);
              break;
              
            case '<':
              const groupName = parts[i].slice(2, -1);
              if (Object.hasOwn(groups, groupName)) {
                result += groups[groupName] ?? '';
              }
              break;
              
            default:
              const groupIndex = parts[i].substring(1);
              let gi = Number(groupIndex);
              
              if (1 <= gi && gi < args.length) {
                result += args[gi];
                break;
              }
    
              if (groupIndex.length === 2) {
                gi = Number(groupIndex.charAt(0));
                if (1 <= gi && gi < args.length) {
                  result += args[gi] + groupIndex.charAt(1);
                  break;
                }
              }
    
              result += parts[i];
          }
          
          result += parts[i + 1];
        }
        
        return result;
      };
    };
    
    console.log('foo bar baz'.replace(/ba./g, '($&)'));
    console.log('foo bar baz'.replace(/ba./g, toReplacer('($&)')));

    (If you drop strict compatibility in edge cases like $12 being interpreted as $012 depending on the actual number of groups, you can simplify it a lot.)

    As a side note, on the off chance that this behavior change isn’t motivated by any other problem: existing text editors typically don’t special-case this, and exhibit your original behavior. It’s worth considering whether you need to change it at all.