Repository: highlightjs/highlight.js (JavaScript)

Issue:
Todo: Multi-class should allow capturing match groups
**Is your request related to a specific problem you're having?**

https://github.com/highlightjs/highlight.js/pull/3081

Multi-class support does not currently allow capturing match groups.  _This should be allowed._

**The solution you'd prefer / feature you'd like to see added...**

We should count the match groups, renumber them, and also renumber the keys passed to `className` (to account for any new "gaps" that the capture groups have introduced).  We have most of the code for this in `regex.js` already since we have to do a lot of this for our MultiRegexMatcher.

**Any alternative solutions you considered...**

None.

**Additional context...**

None.



Discussion:

Patch:
diff --git a/src/highlight.js b/src/highlight.js
index f1008a50f8..b900b8848f 100644
--- a/src/highlight.js
+++ b/src/highlight.js
@@ -249,11 +249,13 @@ const HLJS = function(hljs) {
       let i = 1;
       // eslint-disable-next-line no-undefined
       while (match[i] !== undefined) {
+        if (!mode._emit[i]) { i++; continue; }
         const klass = language.classNameAliases[mode.className[i]] || mode.className[i];
         const text = match[i];
-        if (klass) { emitter.addKeyword(text, klass); } else {
+        if (klass) {
+          emitter.addKeyword(text, klass);
+        } else {
           modeBuffer = text;
-          // emitter.addText(text);
           processKeywords();
         }
         i++;
diff --git a/src/lib/ext/multi_class.js b/src/lib/ext/multi_class.js
index c91f74c50a..6d289dbdf4 100644
--- a/src/lib/ext/multi_class.js
+++ b/src/lib/ext/multi_class.js
@@ -5,7 +5,52 @@ import * as regex from "../regex.js";
 const MultiClassError = new Error();
 
 /**
+ * Renumbers labeled scope names to account for additional inner match
+ * groups that otherwise would break everything.
  *
+ * Lets say we 3 match scopes:
+ *
+ *   { 1 => ..., 2 => ..., 3 => ... }
+ *
+ * So what we need is a clean match like this:
+ *
+ *   (a)(b)(c) => [ "a", "b", "c" ]
+ *
+ * But this falls apart with inner match groups:
+ *
+ * (a)(((b)))(c) => ["a", "b", "b", "b", "c" ]
+ *
+ * Our scopes are now "out of alignment" and we're repeating `b` 3 times.
+ * What needs to happen is the numbers are remapped:
+ *
+ *   { 1 => ..., 2 => ..., 5 => ... }
+ *
+ * We also need to know that the ONLY groups that should be output
+ * are 1, 2, and 5.  This function handles this behavior.
+ *
+ * @param {CompiledMode} mode
+ * @param {Array<RegExp>} regexes
+ */
+function remapScopeNames(mode, regexes) {
+  let offset = 0;
+  const scopeNames = mode.className;
+  /** @type Record<number,boolean> */
+  const emit = {};
+  /** @type Record<number,string|true> */
+  const positions = {};
+
+  for (let i = 1; i <= regexes.length; i++) {
+    positions[i + offset] = scopeNames[i];
+    emit[i + offset] = true;
+    offset += regex.countMatchGroups(regexes[i - 1]);
+  }
+  // we use _emit to keep track of which match groups are "top-level" to avoid double
+  // output from inside match groups
+  mode._emit = emit;
+  mode.className = positions;
+}
+
+/**
  * @param {CompiledMode} mode
  */
 export function MultiClass(mode) {
@@ -16,12 +61,13 @@ export function MultiClass(mode) {
     throw MultiClassError;
   }
 
-  if (typeof mode.className !== "object") {
+  if (typeof mode.className !== "object" || mode.className == null) {
     logger.error("className must be object");
     throw MultiClassError;
   }
 
-  const items = mode.begin.map(x => regex.concat("(", x, ")"));
-  mode.begin = regex.concat(...items);
+  const matchers = mode.begin;
+  remapScopeNames(mode, matchers);
+  mode.begin = regex._rewriteBackreferences(mode.begin, { joinWith: "" });
   mode.isMultiClass = true;
 }
diff --git a/src/lib/mode_compiler.js b/src/lib/mode_compiler.js
index 84c0984f5f..c97ab09d15 100644
--- a/src/lib/mode_compiler.js
+++ b/src/lib/mode_compiler.js
@@ -68,7 +68,7 @@ export function compileLanguage(language, { plugins }) {
         this.exec = () => null;
       }
       const terminators = this.regexes.map(el => el[1]);
-      this.matcherRe = langRe(regex._eitherRewriteBackreferences(terminators), true);
+      this.matcherRe = langRe(regex._rewriteBackreferences(terminators, { joinWith: '|' }), true);
       this.lastIndex = 0;
     }
 
diff --git a/src/lib/regex.js b/src/lib/regex.js
index 3422666df8..362d32c54c 100644
--- a/src/lib/regex.js
+++ b/src/lib/regex.js
@@ -97,10 +97,10 @@ const BACKREF_RE = /\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;
 // is currently an exercise for the caller. :-)
 /**
  * @param {(string | RegExp)[]} regexps
- * @param {string} separator
+ * @param {{joinWith: string}} opts
  * @returns {string}
  */
-export function _eitherRewriteBackreferences(regexps) {
+export function _rewriteBackreferences(regexps, { joinWith }) {
   let numCaptures = 0;
 
   return regexps.map((regex) => {
@@ -128,5 +128,5 @@ export function _eitherRewriteBackreferences(regexps) {
       }
     }
     return out;
-  }).map(re => `(${re})`).join("|");
+  }).map(re => `(${re})`).join(joinWith);
 }


Test patch:
diff --git a/test/api/multiClassMatch.js b/test/api/multiClassMatch.js
index c534e2a335..9bb8186476 100644
--- a/test/api/multiClassMatch.js
+++ b/test/api/multiClassMatch.js
@@ -1,6 +1,7 @@
 'use strict';
 
 const hljs = require('../../build');
+hljs.debugMode();
 
 describe('multi-class matchers', () => {
   before(() => {
@@ -36,7 +37,7 @@ describe('multi-class matchers', () => {
           },
           {
             match: [
-              /func/,
+              /((func))/,
               /\(\)/,
               /{.*}/
             ],