Fix syntax worker based highlighting

HighlightJS produces HTML highlighting in the form of
`<span class="something">text</span>`. But if such an HTML string is
the *input* for syntax highlighting, then our parser would choke on
translating the highlighted HTML back to ranges. Because at the end
it would also interpret the actual code as being a syntax highlighting
marker.

The fix is to keep track of where we are within a line of highlighted
code instead of trying to always match from the beginning of the line.

Google-Bug-Id: b/222020522
Release-Notes: skip
Change-Id: Ie977bd0964df42b7bb7bff870bb064ee45c4f4be
diff --git a/polygerrit-ui/app/utils/syntax-util.ts b/polygerrit-ui/app/utils/syntax-util.ts
index 9cba96d..2710ead 100644
--- a/polygerrit-ui/app/utils/syntax-util.ts
+++ b/polygerrit-ui/app/utils/syntax-util.ts
@@ -22,7 +22,7 @@
  * is really that simple:
  * https://github.com/highlightjs/highlight.js/blob/main/src/lib/html_renderer.js
  */
-const openingSpan = new RegExp('<span class="(.*?)">');
+const openingSpan = new RegExp('<span class="([^"]*?)">');
 const closingSpan = new RegExp('</span>');
 
 /**
@@ -65,7 +65,12 @@
     // For each closing </span> close the latest unclosed range.
     let removal: SpanRemoval | undefined;
     line = unescapeHTML(line);
-    while ((removal = removeFirstSpan(line)) !== undefined) {
+    // We are keeping track of where we are within the line going from left to
+    // right, because the "decoded" string may end up looking like a
+    // highlighting span. Thus `removeFirstSpan()` must not keep matching from
+    // the beginning of the line once it has started removing already.
+    let minOffset = 0;
+    while ((removal = removeFirstSpan(line, minOffset)) !== undefined) {
       if (removal.type === SpanType.OPENING) {
         ranges.push({
           start: removal.offset,
@@ -76,6 +81,7 @@
         const unclosed = lastUnclosed(ranges);
         unclosed.length = removal.offset - unclosed.start;
       }
+      minOffset = removal.offset;
       line = removal.lineAfter;
     }
 
@@ -126,25 +132,34 @@
 /**
  * Finds the first <span ...> or </span>, removes it from the line and returns
  * details about the removal. Returns `undefined`, if neither is found.
+ *
+ * @param minOffset Searches for matches only beyond this offset.
  */
-export function removeFirstSpan(line: string): SpanRemoval | undefined {
-  const openingMatch = openingSpan.exec(line);
+export function removeFirstSpan(
+  line: string,
+  minOffset = 0
+): SpanRemoval | undefined {
+  const partialLine = line.slice(minOffset);
+  const openingMatch = openingSpan.exec(partialLine);
   const openingIndex = openingMatch?.index ?? Number.MAX_VALUE;
-  const closingMatch = closingSpan.exec(line);
+  const closingMatch = closingSpan.exec(partialLine);
   const closingIndex = closingMatch?.index ?? Number.MAX_VALUE;
   if (openingIndex === Number.MAX_VALUE && closingIndex === Number.MAX_VALUE) {
     return undefined;
   }
   const type =
     openingIndex < closingIndex ? SpanType.OPENING : SpanType.CLOSING;
-  const offset = type === SpanType.OPENING ? openingIndex : closingIndex;
+  const partialOffset = type === SpanType.OPENING ? openingIndex : closingIndex;
   const match = type === SpanType.OPENING ? openingMatch : closingMatch;
   if (match === null) return undefined;
   const length = match[0].length;
   const removal: SpanRemoval = {
     type,
-    lineAfter: line.slice(0, offset) + line.slice(offset + length),
-    offset,
+    lineAfter:
+      line.slice(0, minOffset) +
+      partialLine.slice(0, partialOffset) +
+      partialLine.slice(partialOffset + length),
+    offset: minOffset + partialOffset,
     class: type === SpanType.OPENING ? match[1] : undefined,
   };
   return removal;
diff --git a/polygerrit-ui/app/utils/syntax-util_test.ts b/polygerrit-ui/app/utils/syntax-util_test.ts
index fef908a..226f962 100644
--- a/polygerrit-ui/app/utils/syntax-util_test.ts
+++ b/polygerrit-ui/app/utils/syntax-util_test.ts
@@ -99,6 +99,27 @@
       );
     });
 
+    test('one complex line with escaped HTML', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '  <span class="tag">&lt;<span class="name">span</span> <span class="attr">class</span>=<span class="string">&quot;title&quot;</span>&gt;</span>[[name]]<span class="tag">&lt;/<span class="name">span</span>&gt;</span>'
+        ),
+        [
+          {
+            ranges: [
+              // '  <span class="title">[[name]]</span>'
+              {start: 2, length: 20, className: 'tag'},
+              {start: 3, length: 4, className: 'name'},
+              {start: 8, length: 5, className: 'attr'},
+              {start: 14, length: 7, className: 'string'},
+              {start: 30, length: 7, className: 'tag'},
+              {start: 32, length: 4, className: 'name'},
+            ],
+          },
+        ]
+      );
+    });
+
     test('two lines, one span each', async () => {
       assert.deepEqual(
         highlightedStringToRanges(