diff options
author | 7PH <benjamin.raymond@sonarsource.com> | 2024-07-23 16:59:24 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-08-13 20:02:46 +0000 |
commit | 294afa38afda74f98f15f1edca798c67322502b8 (patch) | |
tree | c809418be7fe8ac106cb5e760939d4c7d3cd0fc4 /server | |
parent | 9dcda2987391f2cf2d5370e96202e9f6b07879b6 (diff) | |
download | sonarqube-294afa38afda74f98f15f1edca798c67322502b8.tar.gz sonarqube-294afa38afda74f98f15f1edca798c67322502b8.zip |
SONAR-22494 Create a highlight.js plugin to underline issues in code snippets
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
Diffstat (limited to 'server')
10 files changed, 602 insertions, 44 deletions
diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx index 015b567447e..502cc4a9eae 100644 --- a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx +++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx @@ -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`} diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap index 905780b769e..838e266cad2 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap @@ -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 index 00000000000..e1aec179d9a --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts @@ -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 index 00000000000..5e3b0db0ff2 --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsUnderlinePlugin-test.ts @@ -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 index 00000000000..b7394666777 --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts @@ -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'; diff --git a/server/sonar-web/design-system/src/sonar-aligned/index.ts b/server/sonar-web/design-system/src/sonar-aligned/index.ts index 380e82087fb..2a116ed0d06 100644 --- a/server/sonar-web/design-system/src/sonar-aligned/index.ts +++ b/server/sonar-web/design-system/src/sonar-aligned/index.ts @@ -20,4 +20,5 @@ export * from './components'; export * from './helpers'; +export * from './hljs'; export * from './types'; diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx index 6481815bebf..6b220276678 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx @@ -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'); }); }); }); diff --git a/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/JupyterNotebookIssueViewer.tsx b/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/JupyterNotebookIssueViewer.tsx index 2bf80cdd209..66e5f7bd168 100644 --- a/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/JupyterNotebookIssueViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/JupyterNotebookIssueViewer.tsx @@ -17,10 +17,15 @@ * 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} /> ))} </> diff --git a/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts b/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts index 3bfd497964a..83cbddda97f 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts +++ b/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts @@ -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 }, ]); }); }); diff --git a/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts b/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts index c80d074f19d..adb16a3756a 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts +++ b/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts @@ -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), }); } |