]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22494 Create a highlight.js plugin to underline issues in code snippets
author7PH <benjamin.raymond@sonarsource.com>
Tue, 23 Jul 2024 14:59:24 +0000 (16:59 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 13 Aug 2024 20:02:46 +0000 (20:02 +0000)
SONAR-22495 Fix tokens not being replaced by underlines in some cases & SONAR-22492 Fix underlining being shifted when the line contains double quotes (#11425)

SONAR-22495 Support underlining multiple issues in the same code snippet

SONAR-22643 Allow to simultaneously use the HLJS Underline and Issue Indicator plugin

server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/index.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx
server/sonar-web/src/main/js/apps/issues/jupyter-notebook/JupyterNotebookIssueViewer.tsx
server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts
server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts

index 015b567447e2aaac384e49bc4cea984c3da0efc3..502cc4a9eaebc0fbc0c2d1cac9335e89e935538c 100644 (file)
@@ -25,6 +25,7 @@ import cobol from 'highlightjs-cobol';
 import abap from 'highlightjs-sap-abap';
 import tw from 'twin.macro';
 import { themeColor, themeContrast } from '../helpers/theme';
+import { hljsUnderlinePlugin } from '../sonar-aligned/hljs/HljsUnderlinePlugin';
 
 hljs.registerLanguage('abap', abap);
 hljs.registerLanguage('apex', apex);
@@ -38,6 +39,8 @@ hljs.registerAliases('secrets', { languageName: 'markdown' });
 hljs.registerAliases('web', { languageName: 'xml' });
 hljs.registerAliases(['cloudformation', 'kubernetes'], { languageName: 'yaml' });
 
+hljs.addPlugin(hljsUnderlinePlugin);
+
 interface Props {
   className?: string;
   htmlAsString: string;
@@ -153,6 +156,13 @@ const StyledSpan = styled.span`
     color: ${themeColor('codeSnippetPreprocessingDirective')};
   }
 
+  .sonar-underline {
+    text-decoration: underline ${themeColor('codeLineIssueSquiggle')};
+    text-decoration: underline ${themeColor('codeLineIssueSquiggle')} wavy;
+    text-decoration-thickness: 2px;
+    text-decoration-skip-ink: none;
+  }
+
   &.code-wrap {
     ${tw`sw-whitespace-pre-wrap`}
     ${tw`sw-break-all`}
index 905780b769e68d53d42600fa3896604432e72773..838e266cad24b6838db1655c4142cbabf18d8c0d 100644 (file)
@@ -147,6 +147,15 @@ exports[`should highlight code content correctly 1`] = `
   color: rgb(47,103,48);
 }
 
+.emotion-6 .sonar-underline {
+  -webkit-text-decoration: underline rgb(253,162,155);
+  text-decoration: underline rgb(253,162,155);
+  -webkit-text-decoration: underline rgb(253,162,155) wavy;
+  text-decoration: underline rgb(253,162,155) wavy;
+  text-decoration-thickness: 2px;
+  text-decoration-skip-ink: none;
+}
+
 .emotion-6.code-wrap {
   white-space: pre-wrap;
   word-break: break-all;
@@ -352,6 +361,15 @@ exports[`should show full size when multiline with no editing 1`] = `
   color: rgb(47,103,48);
 }
 
+.emotion-6 .sonar-underline {
+  -webkit-text-decoration: underline rgb(253,162,155);
+  text-decoration: underline rgb(253,162,155);
+  -webkit-text-decoration: underline rgb(253,162,155) wavy;
+  text-decoration: underline rgb(253,162,155) wavy;
+  text-decoration-thickness: 2px;
+  text-decoration-skip-ink: none;
+}
+
 .emotion-6.code-wrap {
   white-space: pre-wrap;
   word-break: break-all;
@@ -561,6 +579,15 @@ exports[`should show reduced size when single line with no editing 1`] = `
   color: rgb(47,103,48);
 }
 
+.emotion-6 .sonar-underline {
+  -webkit-text-decoration: underline rgb(253,162,155);
+  text-decoration: underline rgb(253,162,155);
+  -webkit-text-decoration: underline rgb(253,162,155) wavy;
+  text-decoration: underline rgb(253,162,155) wavy;
+  text-decoration-thickness: 2px;
+  text-decoration-skip-ink: none;
+}
+
 .emotion-6.code-wrap {
   white-space: pre-wrap;
   word-break: break-all;
diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts
new file mode 100644 (file)
index 0000000..e1aec17
--- /dev/null
@@ -0,0 +1,203 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { HighlightResult } from 'highlight.js';
+
+interface UnderlineRangePosition {
+  cursorOffset: number;
+  line: number;
+}
+
+interface UnderlineRange {
+  end: UnderlineRangePosition;
+  start: UnderlineRangePosition;
+}
+
+export class HljsUnderlinePlugin {
+  static readonly SPAN_REGEX = '<\\/?span[^>]*>';
+
+  static readonly TOKEN_PREFIX = 'SNR_TGXRJVF'; // Random string to avoid conflicts with real code
+
+  static readonly TOKEN_SUFFIX_START = '_START';
+
+  static readonly TOKEN_SUFFIX_END = '_END';
+
+  static readonly TOKEN_START =
+    HljsUnderlinePlugin.TOKEN_PREFIX + HljsUnderlinePlugin.TOKEN_SUFFIX_START;
+
+  static readonly TOKEN_END =
+    HljsUnderlinePlugin.TOKEN_PREFIX + HljsUnderlinePlugin.TOKEN_SUFFIX_END;
+
+  static readonly OPEN_TAG = '<span data-testid="hljs-sonar-underline" class="sonar-underline">';
+
+  static readonly CLOSE_TAG = '</span>';
+
+  /**
+   * Add a pair of tokens to the source code to mark the start and end of the content to be underlined.
+   */
+  tokenize(source: string[], ranges: UnderlineRange[]): string[] {
+    // Order ranges by start position, ascending
+    ranges.sort((a, b) => {
+      if (a.start.line === b.start.line) {
+        return a.start.cursorOffset - b.start.cursorOffset;
+      }
+      return a.start.line - b.start.line;
+    });
+
+    // We want to merge overlapping ranges to ensure the underline markup doesn't intesect with itself in the after hook
+    const simplifiedRanges: UnderlineRange[] = [];
+    let currentRange = ranges[0];
+    for (let i = 1; i < ranges.length; i++) {
+      const nextRange = ranges[i];
+
+      if (
+        currentRange.start.line <= nextRange.start.line &&
+        currentRange.start.cursorOffset <= nextRange.start.cursorOffset &&
+        currentRange.end.line >= nextRange.end.line &&
+        currentRange.end.cursorOffset >= nextRange.end.cursorOffset
+      ) {
+        // Range is contained in the current range. Do nothing
+      } else if (
+        currentRange.end.line >= nextRange.start.line &&
+        currentRange.end.cursorOffset >= nextRange.start.cursorOffset
+      ) {
+        // Ranges overlap
+        currentRange.end = nextRange.end;
+      } else {
+        simplifiedRanges.push(currentRange);
+        currentRange = nextRange;
+      }
+    }
+    simplifiedRanges.push(currentRange);
+
+    // Add tokens to the source code, from the end to the start to avoid messing up the indices
+    for (let i = simplifiedRanges.length - 1; i >= 0; i--) {
+      const range = simplifiedRanges[i];
+
+      source[range.end.line] = [
+        source[range.end.line].slice(0, range.end.cursorOffset),
+        HljsUnderlinePlugin.TOKEN_END,
+        source[range.end.line].slice(range.end.cursorOffset),
+      ].join('');
+
+      // If there are lines between the start and end, we re-tokenize each line
+      if (range.end.line !== range.start.line) {
+        source[range.end.line] = HljsUnderlinePlugin.TOKEN_START + source[range.end.line];
+        for (let j = range.end.line - 1; j > range.start.line; j--) {
+          source[j] = [
+            HljsUnderlinePlugin.TOKEN_START,
+            source[j],
+            HljsUnderlinePlugin.TOKEN_END,
+          ].join('');
+        }
+        source[range.start.line] += HljsUnderlinePlugin.TOKEN_END;
+      }
+
+      source[range.start.line] = [
+        source[range.start.line].slice(0, range.start.cursorOffset),
+        HljsUnderlinePlugin.TOKEN_START,
+        source[range.start.line].slice(range.start.cursorOffset),
+      ].join('');
+    }
+
+    return source;
+  }
+
+  'after:highlight'(result: HighlightResult) {
+    const re = new RegExp(HljsUnderlinePlugin.TOKEN_START, 'g');
+    re.lastIndex = 0;
+    let match = re.exec(result.value);
+    while (match) {
+      result.value = this.replaceTokens(result.value, match.index);
+      match = re.exec(result.value);
+    }
+  }
+
+  /**
+   * Whether the content is intersecting with HTML <span> tags added by HLJS or this plugin.
+   */
+  isIntersectingHtmlMarkup(content: string) {
+    const re = new RegExp(HljsUnderlinePlugin.SPAN_REGEX, 'g');
+    let depth = 0;
+    let intersecting = false;
+    let tag = re.exec(content);
+    while (tag) {
+      if (tag[0].startsWith('</')) {
+        depth--;
+      } else {
+        depth++;
+      }
+
+      // If at any point we're closing one-too-many tag, we're intersecting
+      if (depth < 0) {
+        intersecting = true;
+        break;
+      }
+
+      tag = re.exec(content);
+    }
+
+    // If at the end we're not at 0, we're intersecting
+    intersecting = intersecting || depth !== 0;
+
+    return intersecting;
+  }
+
+  /**
+   * Replace a pair of tokens and everything between with the appropriate HTML markup to underline the content.
+   */
+  private replaceTokens(htmlMarkup: string, startTokenIndex: number) {
+    const endTagIndex = htmlMarkup.indexOf(HljsUnderlinePlugin.TOKEN_END);
+
+    // Just in case the end tag is before the start tag (or the end tag isn't found)
+    if (endTagIndex <= startTokenIndex) {
+      return htmlMarkup;
+    }
+
+    let content = htmlMarkup.slice(
+      startTokenIndex + HljsUnderlinePlugin.TOKEN_START.length,
+      endTagIndex,
+    );
+
+    // If intersecting, we highlight in a safe way
+    // We could always use this method, but this creates visual artifacts in the underline wave
+    if (this.isIntersectingHtmlMarkup(content)) {
+      content = content.replace(
+        new RegExp(HljsUnderlinePlugin.SPAN_REGEX, 'g'),
+        (tag) => `${HljsUnderlinePlugin.CLOSE_TAG}${tag}${HljsUnderlinePlugin.OPEN_TAG}`,
+      );
+    }
+
+    // If no intersection, it's safe to add the tags
+    const stringRegex = [
+      HljsUnderlinePlugin.TOKEN_START,
+      '(.+?)',
+      HljsUnderlinePlugin.TOKEN_END,
+    ].join('');
+    htmlMarkup = htmlMarkup.replace(
+      new RegExp(stringRegex, 's'),
+      `${HljsUnderlinePlugin.OPEN_TAG}${content}${HljsUnderlinePlugin.CLOSE_TAG}`,
+    );
+
+    return htmlMarkup;
+  }
+}
+
+const hljsUnderlinePlugin = new HljsUnderlinePlugin();
+export { hljsUnderlinePlugin };
diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts
new file mode 100644 (file)
index 0000000..5e3b0db
--- /dev/null
@@ -0,0 +1,187 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { HighlightResult } from 'highlight.js';
+import { HljsUnderlinePlugin, hljsUnderlinePlugin } from '../HljsUnderlinePlugin';
+
+const START_TOKEN = HljsUnderlinePlugin.TOKEN_START;
+const END_TOKEN = HljsUnderlinePlugin.TOKEN_END;
+
+describe('should add tokens', () => {
+  it('with multiple overlapping ranges', () => {
+    expect(
+      hljsUnderlinePlugin.tokenize(
+        ['line1', 'line2', 'line3', 'line4', 'line5'],
+        [
+          {
+            start: { line: 1, cursorOffset: 2 },
+            end: { line: 2, cursorOffset: 2 },
+          },
+          {
+            start: { line: 3, cursorOffset: 2 },
+            end: { line: 3, cursorOffset: 4 },
+          },
+          {
+            start: { line: 1, cursorOffset: 1 },
+            end: { line: 1, cursorOffset: 3 },
+          },
+        ],
+      ),
+    ).toEqual([
+      'line1',
+      `l${START_TOKEN}ine2${END_TOKEN}`,
+      `${START_TOKEN}li${END_TOKEN}ne3`,
+      `li${START_TOKEN}ne${END_TOKEN}4`,
+      'line5',
+    ]);
+  });
+
+  it('highlight multiple issues on the same line', () => {
+    expect(
+      hljsUnderlinePlugin.tokenize(
+        ['line1', 'line2', 'line3', 'line4', 'line5'],
+        [
+          {
+            start: { line: 1, cursorOffset: 1 },
+            end: { line: 1, cursorOffset: 2 },
+          },
+          {
+            start: { line: 1, cursorOffset: 3 },
+            end: { line: 1, cursorOffset: 4 },
+          },
+        ],
+      ),
+    ).toEqual([
+      'line1',
+      `l${START_TOKEN}i${END_TOKEN}n${START_TOKEN}e${END_TOKEN}2`,
+      'line3',
+      'line4',
+      'line5',
+    ]);
+  });
+
+  it('highlight multiple successive lines', () => {
+    expect(
+      hljsUnderlinePlugin.tokenize(
+        ['line1', 'line2', 'line3', 'line4', 'line5'],
+        [
+          {
+            start: { line: 1, cursorOffset: 2 },
+            end: { line: 4, cursorOffset: 4 },
+          },
+        ],
+      ),
+    ).toEqual([
+      'line1',
+      `li${START_TOKEN}ne2${END_TOKEN}`,
+      `${START_TOKEN}line3${END_TOKEN}`,
+      `${START_TOKEN}line4${END_TOKEN}`,
+      `${START_TOKEN}line${END_TOKEN}5`,
+    ]);
+  });
+});
+
+describe('should detect html markup intersection', () => {
+  it.each([
+    '... <span a="b"> ....',
+    '... </span> ...',
+    '<span> ...',
+    '... </span>',
+    '... </span> ... <span a="b"> ...',
+    '... <span><span a="b"> ... </span> ...',
+    '... <span> ... <span a="b"> ... </span> ... </span> ... </span> ...',
+  ])('should detect intersection (%s)', (code) => {
+    expect(hljsUnderlinePlugin.isIntersectingHtmlMarkup(code)).toBe(true);
+  });
+
+  it.each([
+    '... <span a="b"> ... </span> ...',
+    '<span> ... </span> ... <span> ... <span class="abc"><span> ... </span></span> ... </span>',
+  ])('should not detect intersection (%s)', (code) => {
+    expect(hljsUnderlinePlugin.isIntersectingHtmlMarkup(code)).toBe(false);
+  });
+});
+
+describe('underline plugin should work', () => {
+  it('should underline on different lines', () => {
+    const result = {
+      value: ['line1', `l${START_TOKEN}ine2`, 'line3', `lin${END_TOKEN}e4`, 'line5'].join('\n'),
+    } as HighlightResult;
+
+    hljsUnderlinePlugin['after:highlight'](result);
+
+    expect(result.value).toEqual(
+      [
+        'line1',
+        `l${HljsUnderlinePlugin.OPEN_TAG}ine2`,
+        'line3',
+        `lin${HljsUnderlinePlugin.CLOSE_TAG}e4`,
+        'line5',
+      ].join('\n'),
+    );
+  });
+
+  it('should underline on same lines', () => {
+    const result = {
+      value: ['line1', `l${START_TOKEN}ine${END_TOKEN}2`, 'line3'].join('\n'),
+    } as HighlightResult;
+
+    hljsUnderlinePlugin['after:highlight'](result);
+
+    expect(result.value).toEqual(
+      [
+        'line1',
+        `l${HljsUnderlinePlugin.OPEN_TAG}ine${HljsUnderlinePlugin.CLOSE_TAG}2`,
+        'line3',
+      ].join('\n'),
+    );
+  });
+
+  it('should not underline if end tag is before start tag', () => {
+    const result = {
+      value: ['line1', `l${END_TOKEN}ine${START_TOKEN}2`, 'line3'].join('\n'),
+    } as HighlightResult;
+
+    hljsUnderlinePlugin['after:highlight'](result);
+
+    expect(result.value).toEqual(['line1', `l${END_TOKEN}ine${START_TOKEN}2`, 'line3'].join('\n'));
+  });
+
+  it('should not underline if there is no end tag', () => {
+    const result = {
+      value: ['line1', `l${START_TOKEN}ine2`, 'line3'].join('\n'),
+    } as HighlightResult;
+
+    hljsUnderlinePlugin['after:highlight'](result);
+
+    expect(result.value).toEqual(['line1', `l${START_TOKEN}ine2`, 'line3'].join('\n'));
+  });
+
+  it('should underline even when intersecting html markup', () => {
+    const result = {
+      value: `.. <span class="hljs-keyword"> .${START_TOKEN}. <span class="hljs-keyword"> .. </span> .. </span> .. ${END_TOKEN} ..`,
+    } as HighlightResult;
+
+    hljsUnderlinePlugin['after:highlight'](result);
+
+    expect(result.value).toEqual(
+      `.. <span class="hljs-keyword"> .${HljsUnderlinePlugin.OPEN_TAG}. ${HljsUnderlinePlugin.CLOSE_TAG}<span class="hljs-keyword">${HljsUnderlinePlugin.OPEN_TAG} .. ${HljsUnderlinePlugin.CLOSE_TAG}</span>${HljsUnderlinePlugin.OPEN_TAG} .. ${HljsUnderlinePlugin.CLOSE_TAG}</span>${HljsUnderlinePlugin.OPEN_TAG} .. ${HljsUnderlinePlugin.CLOSE_TAG} ..`,
+    );
+  });
+});
diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts
new file mode 100644 (file)
index 0000000..b739466
--- /dev/null
@@ -0,0 +1,20 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+export { hljsUnderlinePlugin } from './HljsUnderlinePlugin';
index 380e82087fbb82fc5aad568d6696a155c5c49057..2a116ed0d0670ba39b2f31867f998a96b46c445e 100644 (file)
@@ -20,4 +20,5 @@
 
 export * from './components';
 export * from './helpers';
+export * from './hljs';
 export * from './types';
index 6481815bebf94154965e500af471da442cb97203..6b220276678aaa63a093895a5a9bc2ae7a7bce70 100644 (file)
@@ -71,8 +71,8 @@ const JUPYTER_ISSUE = {
     textRange: {
       startLine: 1,
       endLine: 1,
-      startOffset: 1142,
-      endOffset: 1144,
+      startOffset: 1148,
+      endOffset: 1159,
     },
     ruleDescriptionContextKey: 'spring',
     ruleStatus: 'DEPRECATED',
@@ -185,6 +185,35 @@ describe('issues source viewer', () => {
       expect(screen.getByText('issue.preview.jupyter_notebook.error')).toBeInTheDocument();
     });
 
+    it('should render error when jupyter issue can not be found', async () => {
+      issuesHandler.setIssueList([
+        {
+          ...JUPYTER_ISSUE,
+          issue: {
+            ...JUPYTER_ISSUE.issue,
+            textRange: {
+              startLine: 2,
+              endLine: 2,
+              startOffset: 1,
+              endOffset: 1,
+            },
+          },
+        },
+      ]);
+      renderProjectIssuesApp('project/issues?issues=some-issue&open=some-issue&id=myproject');
+      await waitOnDataLoaded();
+
+      // Preview tab should be shown
+      expect(ui.preview.get()).toBeChecked();
+      expect(ui.code.get()).toBeInTheDocument();
+
+      expect(
+        await screen.findByRole('button', { name: 'Issue on Jupyter Notebook' }),
+      ).toBeInTheDocument();
+
+      expect(screen.getByText('issue.preview.jupyter_notebook.error')).toBeInTheDocument();
+    });
+
     it('should show preview tab when jupyter notebook issue', async () => {
       issuesHandler.setIssueList([JUPYTER_ISSUE]);
       renderProjectIssuesApp('project/issues?issues=some-issue&open=some-issue&id=myproject');
@@ -199,6 +228,44 @@ describe('issues source viewer', () => {
       ).toBeInTheDocument();
 
       expect(screen.queryByText('issue.preview.jupyter_notebook.error')).not.toBeInTheDocument();
+      expect(screen.getByTestId('hljs-sonar-underline')).toHaveTextContent('matplotlib');
+      expect(screen.getByText(/pylab/, { exact: false })).toBeInTheDocument();
+    });
+
+    it('should render issue in jupyter notebook spanning over multiple cells', async () => {
+      issuesHandler.setIssueList([
+        {
+          ...JUPYTER_ISSUE,
+          issue: {
+            ...JUPYTER_ISSUE.issue,
+            textRange: {
+              startLine: 1,
+              endLine: 1,
+              startOffset: 571,
+              endOffset: JUPYTER_ISSUE.issue.textRange!.endOffset,
+            },
+          },
+        },
+      ]);
+      renderProjectIssuesApp('project/issues?issues=some-issue&open=some-issue&id=myproject');
+      await waitOnDataLoaded();
+
+      // Preview tab should be shown
+      expect(ui.preview.get()).toBeChecked();
+      expect(ui.code.get()).toBeInTheDocument();
+
+      expect(
+        await screen.findByRole('button', { name: 'Issue on Jupyter Notebook' }),
+      ).toBeInTheDocument();
+
+      expect(screen.queryByText('issue.preview.jupyter_notebook.error')).not.toBeInTheDocument();
+
+      const underlined = screen.getAllByTestId('hljs-sonar-underline');
+      expect(underlined).toHaveLength(4);
+      expect(underlined[0]).toHaveTextContent('print train.shape');
+      expect(underlined[1]).toHaveTextContent('print test.shap');
+      expect(underlined[2]).toHaveTextContent('import pylab as pl');
+      expect(underlined[3]).toHaveTextContent('%matplotlib');
     });
   });
 });
index 2bf80cdd2095a048ad110eaca8d5a38b8a298d21..66e5f7bd168aec67d90c0ab26b87bcbf900fe02b 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { INotebookContent } from '@jupyterlab/nbformat';
+import { ICell, INotebookContent } from '@jupyterlab/nbformat';
 import { Spinner } from '@sonarsource/echoes-react';
-import { FlagMessage, IssueMessageHighlighting, LineFinding } from 'design-system';
-import React, { useMemo } from 'react';
+import {
+  FlagMessage,
+  hljsUnderlinePlugin,
+  IssueMessageHighlighting,
+  LineFinding,
+} from 'design-system';
+import React from 'react';
 import { JupyterCell } from '~sonar-aligned/components/SourceViewer/JupyterNotebookViewer';
 import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
 import { JsonIssueMapper } from '~sonar-aligned/helpers/json-issue-mapper';
@@ -28,7 +33,6 @@ import { translate } from '../../../helpers/l10n';
 import { useRawSourceQuery } from '../../../queries/sources';
 import { BranchLike } from '../../../types/branch-like';
 import { Issue } from '../../../types/types';
-import { JupyterNotebookCursorPath } from './types';
 import { pathToCursorInCell } from './utils';
 
 export interface JupyterNotebookIssueViewerProps {
@@ -42,26 +46,18 @@ export function JupyterNotebookIssueViewer(props: Readonly<JupyterNotebookIssueV
     key: issue.component,
     ...getBranchLikeQuery(branchLike),
   });
-  const [startOffset, setStartOffset] = React.useState<JupyterNotebookCursorPath | null>(null);
-  const [endPath, setEndPath] = React.useState<JupyterNotebookCursorPath | null>(null);
-
-  const jupyterNotebook = useMemo(() => {
-    if (typeof data !== 'string') {
-      return null;
-    }
-    try {
-      return JSON.parse(data) as INotebookContent;
-    } catch (error) {
-      return null;
-    }
-  }, [data]);
+  const [renderedCells, setRenderedCells] = React.useState<ICell[] | null>(null);
 
   React.useEffect(() => {
-    if (typeof data !== 'string') {
+    if (!issue.textRange || typeof data !== 'string') {
       return;
     }
 
-    if (!issue.textRange) {
+    let jupyterNotebook: INotebookContent;
+    try {
+      jupyterNotebook = JSON.parse(data);
+    } catch (error) {
+      setRenderedCells(null);
       return;
     }
 
@@ -76,22 +72,56 @@ export function JupyterNotebookIssueViewer(props: Readonly<JupyterNotebookIssueV
     );
     const startOffset = pathToCursorInCell(mapper.get(start));
     const endOffset = pathToCursorInCell(mapper.get(end));
-    if (
-      startOffset &&
-      endOffset &&
-      startOffset.cell === endOffset.cell &&
-      startOffset.line === endOffset.line
-    ) {
-      setStartOffset(startOffset);
-      setEndPath(endOffset);
+    if (!startOffset || !endOffset) {
+      setRenderedCells(null);
+      return;
     }
+
+    if (startOffset.cell === endOffset.cell) {
+      const startCell = jupyterNotebook.cells[startOffset.cell];
+      startCell.source = Array.isArray(startCell.source) ? startCell.source : [startCell.source];
+      startCell.source = hljsUnderlinePlugin.tokenize(startCell.source, [
+        {
+          start: startOffset,
+          end: endOffset,
+        },
+      ]);
+    } else {
+      // Each cell is a separate code block, so we have to underline them separately
+      // We underilne the first cell from the start offset to the end of the cell, and the last cell from the start of the cell to the end offset
+      const startCell = jupyterNotebook.cells[startOffset.cell];
+      startCell.source = Array.isArray(startCell.source) ? startCell.source : [startCell.source];
+      startCell.source = hljsUnderlinePlugin.tokenize(startCell.source, [
+        {
+          start: startOffset,
+          end: {
+            line: startCell.source.length - 1,
+            cursorOffset: startCell.source[startCell.source.length - 1].length,
+          },
+        },
+      ]);
+      const endCell = jupyterNotebook.cells[endOffset.cell];
+      endCell.source = Array.isArray(endCell.source) ? endCell.source : [endCell.source];
+      endCell.source = hljsUnderlinePlugin.tokenize(endCell.source, [
+        {
+          start: { line: 0, cursorOffset: 0 },
+          end: endOffset,
+        },
+      ]);
+    }
+
+    const cells = Array.from(new Set([startOffset.cell, endOffset.cell])).map(
+      (cellIndex) => jupyterNotebook.cells[cellIndex],
+    );
+
+    setRenderedCells(cells);
   }, [issue, data]);
 
   if (isLoading) {
     return <Spinner />;
   }
 
-  if (!jupyterNotebook || !startOffset || !endPath) {
+  if (!renderedCells) {
     return (
       <FlagMessage className="sw-mt-2" variant="warning">
         {translate('issue.preview.jupyter_notebook.error')}
@@ -99,11 +129,6 @@ export function JupyterNotebookIssueViewer(props: Readonly<JupyterNotebookIssueV
     );
   }
 
-  // Cells to display
-  const cells = Array.from(new Set([startOffset.cell, endPath.cell])).map(
-    (cellIndex) => jupyterNotebook.cells[cellIndex],
-  );
-
   return (
     <>
       <LineFinding
@@ -116,7 +141,7 @@ export function JupyterNotebookIssueViewer(props: Readonly<JupyterNotebookIssueV
         }
         selected
       />
-      {cells.map((cell, index) => (
+      {renderedCells.map((cell, index) => (
         <JupyterCell key={'cell-' + index} cell={cell} />
       ))}
     </>
index 3bfd497964a2df79db2fff8f309b69fc1093be9d..83cbddda97f9b1ddc48d6a5c55535837b70bef89 100644 (file)
@@ -107,7 +107,7 @@ describe('JsonIssueMapper', () => {
         { type: 'array', index: 1 },
         { type: 'object', key: 'data' },
         { type: 'object', key: 'image/png' },
-        { type: 'string', index: 23 },
+        { type: 'string', index: 24 },
       ]);
     });
 
@@ -119,7 +119,7 @@ describe('JsonIssueMapper', () => {
         { type: 'array', index: 1 },
         { type: 'object', key: 'source' },
         { type: 'array', index: 8 },
-        { type: 'string', index: 14 },
+        { type: 'string', index: 15 },
       ]);
     });
   });
index c80d074f19d20750f904d165d2f698728e0f0563..adb16a3756aeee3aceefcae75554d5eed2d5ccdc 100644 (file)
@@ -234,6 +234,29 @@ export class JsonIssueMapper {
     };
   }
 
+  private getStringCursorIndex(firstQuoteIndex: number, endQuoteIndex: number): number {
+    const index = this.cursorPosition - firstQuoteIndex;
+
+    // We make it such that if the cursor is on a quote, it is considered to be within the string
+    if (index <= 0) {
+      return 0;
+    }
+
+    let count = 0;
+    let i = 0;
+    while (i < index) {
+      // Ignore escaped quotes
+      if (this.code[firstQuoteIndex + i] === '\\' && this.code[firstQuoteIndex + i + 1] === '"') {
+        i += 2;
+      } else {
+        i += 1;
+      }
+      count++;
+    }
+
+    return Math.min(count, endQuoteIndex - firstQuoteIndex - 2);
+  }
+
   /**
    * Parse a string value. Place the cursor at the end quote.
    */
@@ -243,14 +266,9 @@ export class JsonIssueMapper {
     // Cursor within string value
     if (this.cursorWithin(firstQuoteIndex, endQuoteIndex)) {
       if (endQuoteIndex - firstQuoteIndex > 1) {
-        // We make it such that if the cursor is on a quote, it is considered to be within the string
-        let index = this.cursorPosition - firstQuoteIndex - 1;
-        index = Math.min(index, endQuoteIndex - firstQuoteIndex - 2);
-        index = Math.max(0, index);
-
         this.path.push({
           type: 'string',
-          index,
+          index: this.getStringCursorIndex(firstQuoteIndex, endQuoteIndex),
         });
       }