| /** |
| * @license |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| import '../../shared/gr-lib-loader/gr-lib-loader'; |
| import {GrAnnotation} from '../gr-diff-highlight/gr-annotation'; |
| import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; |
| import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-syntax-layer_html'; |
| import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line'; |
| import {CancelablePromise, util} from '../../../scripts/util'; |
| import {customElement, property} from '@polymer/decorators'; |
| import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff'; |
| import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types'; |
| import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader'; |
| import {Side} from '../../../constants/constants'; |
| |
| const LANGUAGE_MAP = new Map<string, string>([ |
| ['application/dart', 'dart'], |
| ['application/json', 'json'], |
| ['application/x-powershell', 'powershell'], |
| ['application/typescript', 'typescript'], |
| ['application/xml', 'xml'], |
| ['application/xquery', 'xquery'], |
| ['application/x-erb', 'erb'], |
| ['text/css', 'css'], |
| ['text/html', 'html'], |
| ['text/javascript', 'js'], |
| ['text/jsx', 'jsx'], |
| ['text/tsx', 'jsx'], |
| ['text/x-c', 'cpp'], |
| ['text/x-c++src', 'cpp'], |
| ['text/x-clojure', 'clojure'], |
| ['text/x-cmake', 'cmake'], |
| ['text/x-coffeescript', 'coffeescript'], |
| ['text/x-common-lisp', 'lisp'], |
| ['text/x-crystal', 'crystal'], |
| ['text/x-csharp', 'csharp'], |
| ['text/x-csrc', 'cpp'], |
| ['text/x-d', 'd'], |
| ['text/x-diff', 'diff'], |
| ['text/x-django', 'django'], |
| ['text/x-dockerfile', 'dockerfile'], |
| ['text/x-ebnf', 'ebnf'], |
| ['text/x-elm', 'elm'], |
| ['text/x-erlang', 'erlang'], |
| ['text/x-fortran', 'fortran'], |
| ['text/x-fsharp', 'fsharp'], |
| ['text/x-go', 'go'], |
| ['text/x-groovy', 'groovy'], |
| ['text/x-haml', 'haml'], |
| ['text/x-handlebars', 'handlebars'], |
| ['text/x-haskell', 'haskell'], |
| ['text/x-haxe', 'haxe'], |
| ['text/x-ini', 'ini'], |
| ['text/x-java', 'java'], |
| ['text/x-julia', 'julia'], |
| ['text/x-kotlin', 'kotlin'], |
| ['text/x-latex', 'latex'], |
| ['text/x-less', 'less'], |
| ['text/x-lua', 'lua'], |
| ['text/x-mathematica', 'mathematica'], |
| ['text/x-nginx-conf', 'nginx'], |
| ['text/x-nsis', 'nsis'], |
| ['text/x-objectivec', 'objectivec'], |
| ['text/x-ocaml', 'ocaml'], |
| ['text/x-perl', 'perl'], |
| ['text/x-pgsql', 'pgsql'], // postgresql |
| ['text/x-php', 'php'], |
| ['text/x-properties', 'properties'], |
| ['text/x-protobuf', 'protobuf'], |
| ['text/x-puppet', 'puppet'], |
| ['text/x-python', 'python'], |
| ['text/x-q', 'q'], |
| ['text/x-ruby', 'ruby'], |
| ['text/x-rustsrc', 'rust'], |
| ['text/x-scala', 'scala'], |
| ['text/x-scss', 'scss'], |
| ['text/x-scheme', 'scheme'], |
| ['text/x-shell', 'shell'], |
| ['text/x-soy', 'soy'], |
| ['text/x-spreadsheet', 'excel'], |
| ['text/x-sh', 'bash'], |
| ['text/x-sql', 'sql'], |
| ['text/x-swift', 'swift'], |
| ['text/x-systemverilog', 'sv'], |
| ['text/x-tcl', 'tcl'], |
| ['text/x-torque', 'torque'], |
| ['text/x-twig', 'twig'], |
| ['text/x-vb', 'vb'], |
| ['text/x-verilog', 'v'], |
| ['text/x-vhdl', 'vhdl'], |
| ['text/x-yaml', 'yaml'], |
| ['text/vbscript', 'vbscript'], |
| ]); |
| const ASYNC_DELAY = 10; |
| |
| const CLASS_SAFELIST = new Set<string>([ |
| 'gr-diff gr-syntax gr-syntax-attr', |
| 'gr-diff gr-syntax gr-syntax-attribute', |
| 'gr-diff gr-syntax gr-syntax-built_in', |
| 'gr-diff gr-syntax gr-syntax-comment', |
| 'gr-diff gr-syntax gr-syntax-doctag', |
| 'gr-diff gr-syntax gr-syntax-function', |
| 'gr-diff gr-syntax gr-syntax-keyword', |
| 'gr-diff gr-syntax gr-syntax-link', |
| 'gr-diff gr-syntax gr-syntax-literal', |
| 'gr-diff gr-syntax gr-syntax-meta', |
| 'gr-diff gr-syntax gr-syntax-meta-keyword', |
| 'gr-diff gr-syntax gr-syntax-name', |
| 'gr-diff gr-syntax gr-syntax-number', |
| 'gr-diff gr-syntax gr-syntax-params', |
| 'gr-diff gr-syntax gr-syntax-regexp', |
| 'gr-diff gr-syntax gr-syntax-selector-attr', |
| 'gr-diff gr-syntax gr-syntax-selector-class', |
| 'gr-diff gr-syntax gr-syntax-selector-id', |
| 'gr-diff gr-syntax gr-syntax-selector-pseudo', |
| 'gr-diff gr-syntax gr-syntax-selector-tag', |
| 'gr-diff gr-syntax gr-syntax-string', |
| 'gr-diff gr-syntax gr-syntax-tag', |
| 'gr-diff gr-syntax gr-syntax-template-tag', |
| 'gr-diff gr-syntax gr-syntax-template-variable', |
| 'gr-diff gr-syntax gr-syntax-title', |
| 'gr-diff gr-syntax gr-syntax-type', |
| 'gr-diff gr-syntax gr-syntax-variable', |
| ]); |
| |
| const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</; |
| const CPP_WCHAR_PATTERN = /L'(\\)?.'/g; |
| const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g; |
| const GO_BACKSLASH_LITERAL = "'\\\\'"; |
| const GLOBAL_LT_PATTERN = /</g; |
| |
| interface SyntaxLayerRange { |
| start: number; |
| length: number; |
| className: string; |
| } |
| |
| interface SyntaxLayerState { |
| sectionIndex: number; |
| lineIndex: number; |
| baseContext: unknown; |
| revisionContext: unknown; |
| lineNums: {left: number; right: number}; |
| lastNotify: {left: number; right: number}; |
| } |
| |
| export interface GrSyntaxLayer { |
| $: { |
| libLoader: GrLibLoader; |
| }; |
| } |
| |
| @customElement('gr-syntax-layer') |
| export class GrSyntaxLayer |
| extends GestureEventListeners(LegacyElementMixin(PolymerElement)) |
| implements DiffLayer { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| @property({type: Object, observer: '_diffChanged'}) |
| diff?: DiffInfo; |
| |
| @property({type: Boolean}) |
| enabled = true; |
| |
| @property({type: Array}) |
| _baseRanges: SyntaxLayerRange[][] = []; |
| |
| @property({type: Array}) |
| _revisionRanges: SyntaxLayerRange[][] = []; |
| |
| @property({type: String}) |
| _baseLanguage?: string; |
| |
| @property({type: String}) |
| _revisionLanguage?: string; |
| |
| @property({type: Array}) |
| _listeners: DiffLayerListener[] = []; |
| |
| @property({type: Number}) |
| _processHandle: number | null = null; |
| |
| @property({type: Object}) |
| _processPromise: CancelablePromise<unknown> | null = null; |
| |
| @property({type: Object}) |
| _hljs?: HighlightJS; |
| |
| addListener(listener: DiffLayerListener) { |
| this.push('_listeners', listener); |
| } |
| |
| removeListener(listener: DiffLayerListener) { |
| this._listeners = this._listeners.filter(f => f !== listener); |
| } |
| |
| /** |
| * Annotation layer method to add syntax annotations to the given element |
| * for the given line. |
| */ |
| annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) { |
| if (!this.enabled) return; |
| if (line.beforeNumber === FILE) return; |
| if (line.afterNumber === FILE) return; |
| |
| // Determine the side. |
| let side; |
| if ( |
| line.type === GrDiffLineType.REMOVE || |
| (line.type === GrDiffLineType.BOTH && |
| el.getAttribute('data-side') !== 'right') |
| ) { |
| side = 'left'; |
| } else if ( |
| line.type === GrDiffLineType.ADD || |
| el.getAttribute('data-side') !== 'left' |
| ) { |
| side = 'right'; |
| } |
| |
| // Find the relevant syntax ranges, if any. |
| let ranges: SyntaxLayerRange[] = []; |
| if (side === 'left' && this._baseRanges.length >= line.beforeNumber) { |
| ranges = this._baseRanges[line.beforeNumber - 1] || []; |
| } else if ( |
| side === 'right' && |
| this._revisionRanges.length >= line.afterNumber |
| ) { |
| ranges = this._revisionRanges[line.afterNumber - 1] || []; |
| } |
| |
| // Apply the ranges to the element. |
| for (const range of ranges) { |
| GrAnnotation.annotateElement( |
| el, |
| range.start, |
| range.length, |
| range.className |
| ); |
| } |
| } |
| |
| _getLanguage(metaInfo: DiffFileMetaInfo) { |
| // The Gerrit API provides only content-type, but for other users of |
| // gr-diff it may be more convenient to specify the language directly. |
| return metaInfo.language ?? LANGUAGE_MAP.get(metaInfo.content_type); |
| } |
| |
| /** |
| * Start processing syntax for the loaded diff and notify layer listeners |
| * as syntax info comes online. |
| */ |
| process() { |
| // Cancel any still running process() calls, because they append to the |
| // same _baseRanges and _revisionRanges fields. |
| this.cancel(); |
| |
| // Discard existing ranges. |
| this._baseRanges = []; |
| this._revisionRanges = []; |
| |
| if (!this.enabled || !this.diff?.content.length) { |
| return Promise.resolve(); |
| } |
| |
| if (this.diff.meta_a) { |
| this._baseLanguage = this._getLanguage(this.diff.meta_a); |
| } |
| if (this.diff.meta_b) { |
| this._revisionLanguage = this._getLanguage(this.diff.meta_b); |
| } |
| if (!this._baseLanguage && !this._revisionLanguage) { |
| return Promise.resolve(); |
| } |
| |
| const state: SyntaxLayerState = { |
| sectionIndex: 0, |
| lineIndex: 0, |
| baseContext: undefined, |
| revisionContext: undefined, |
| lineNums: {left: 1, right: 1}, |
| lastNotify: {left: 1, right: 1}, |
| }; |
| |
| const rangesCache = new Map(); |
| |
| this._processPromise = util.makeCancelable( |
| this._loadHLJS().then( |
| () => |
| new Promise(resolve => { |
| const nextStep = () => { |
| this._processHandle = null; |
| this._processNextLine(state, rangesCache); |
| |
| // Move to the next line in the section. |
| state.lineIndex++; |
| |
| // If the section has been exhausted, move to the next one. |
| if (this._isSectionDone(state)) { |
| state.lineIndex = 0; |
| state.sectionIndex++; |
| } |
| |
| // If all sections have been exhausted, finish. |
| if ( |
| !this.diff || |
| state.sectionIndex >= this.diff.content.length |
| ) { |
| resolve(); |
| this._notify(state); |
| return; |
| } |
| |
| if (state.lineIndex % 100 === 0) { |
| this._notify(state); |
| this._processHandle = this.async(nextStep, ASYNC_DELAY); |
| } else { |
| nextStep.call(this); |
| } |
| }; |
| |
| this._processHandle = this.async(nextStep, 1); |
| }) |
| ) |
| ); |
| return this._processPromise.finally(() => { |
| this._processPromise = null; |
| }); |
| } |
| |
| /** |
| * Cancel any asynchronous syntax processing jobs. |
| */ |
| cancel() { |
| if (this._processHandle !== null) { |
| this.cancelAsync(this._processHandle); |
| this._processHandle = null; |
| } |
| if (this._processPromise) { |
| this._processPromise.cancel(); |
| } |
| } |
| |
| _diffChanged() { |
| this.cancel(); |
| this._baseRanges = []; |
| this._revisionRanges = []; |
| } |
| |
| /** |
| * Take a string of HTML with the (potentially nested) syntax markers |
| * Highlight.js emits and emit a list of text ranges and classes for the |
| * markers. |
| * |
| * @param str The string of HTML. |
| * @param rangesCache A map for caching |
| * ranges for each string. A cache is read and written by this method. |
| * Since diff is mostly comparing same file on two sides, there is good rate |
| * of duplication at least for parts that are on left and right parts. |
| * @return The list of ranges. |
| */ |
| _rangesFromString( |
| str: string, |
| rangesCache: Map<string, SyntaxLayerRange[]> |
| ): SyntaxLayerRange[] { |
| const cached = rangesCache.get(str); |
| if (cached) return cached; |
| |
| const div = document.createElement('div'); |
| div.innerHTML = str; |
| const ranges = this._rangesFromElement(div, 0); |
| rangesCache.set(str, ranges); |
| return ranges; |
| } |
| |
| _rangesFromElement(elem: Element, offset: number): SyntaxLayerRange[] { |
| let result: SyntaxLayerRange[] = []; |
| for (const node of elem.childNodes) { |
| const nodeLength = GrAnnotation.getLength(node); |
| // Note: HLJS may emit a span with class undefined when it thinks there |
| // may be a syntax error. |
| if ( |
| node instanceof Element && |
| node.tagName === 'SPAN' && |
| node.className !== 'undefined' |
| ) { |
| if (CLASS_SAFELIST.has(node.className)) { |
| result.push({ |
| start: offset, |
| length: nodeLength, |
| className: node.className, |
| }); |
| } |
| if (node.children.length) { |
| result = result.concat(this._rangesFromElement(node, offset)); |
| } |
| } |
| offset += nodeLength; |
| } |
| return result; |
| } |
| |
| /** |
| * For a given state, process the syntax for the next line (or pair of |
| * lines). |
| */ |
| _processNextLine( |
| state: SyntaxLayerState, |
| rangesCache: Map<string, SyntaxLayerRange[]> |
| ) { |
| if (!this.diff) return; |
| if (!this._hljs) return; |
| |
| let baseLine; |
| let revisionLine; |
| const section = this.diff.content[state.sectionIndex]; |
| if (section.ab) { |
| baseLine = section.ab[state.lineIndex]; |
| revisionLine = section.ab[state.lineIndex]; |
| state.lineNums.left++; |
| state.lineNums.right++; |
| } else { |
| if (section.a && section.a.length > state.lineIndex) { |
| baseLine = section.a[state.lineIndex]; |
| state.lineNums.left++; |
| } |
| if (section.b && section.b.length > state.lineIndex) { |
| revisionLine = section.b[state.lineIndex]; |
| state.lineNums.right++; |
| } |
| } |
| |
| // To store the result of the syntax highlighter. |
| let result; |
| |
| if ( |
| this._baseLanguage && |
| baseLine !== undefined && |
| this._hljs.getLanguage(this._baseLanguage) |
| ) { |
| baseLine = this._workaround(this._baseLanguage, baseLine); |
| result = this._hljs.highlight( |
| this._baseLanguage, |
| baseLine, |
| true, |
| state.baseContext |
| ); |
| this.push( |
| '_baseRanges', |
| this._rangesFromString(result.value, rangesCache) |
| ); |
| state.baseContext = result.top; |
| } |
| |
| if ( |
| this._revisionLanguage && |
| revisionLine !== undefined && |
| this._hljs.getLanguage(this._revisionLanguage) |
| ) { |
| revisionLine = this._workaround(this._revisionLanguage, revisionLine); |
| result = this._hljs.highlight( |
| this._revisionLanguage, |
| revisionLine, |
| true, |
| state.revisionContext |
| ); |
| this.push( |
| '_revisionRanges', |
| this._rangesFromString(result.value, rangesCache) |
| ); |
| state.revisionContext = result.top; |
| } |
| } |
| |
| /** |
| * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained |
| * cases before sending them into HLJS so that they parse correctly. |
| * |
| * Important notes: |
| * * These tests should be as constrained as possible to avoid interfering |
| * with code it shouldn't AND to avoid executing regexes as much as |
| * possible. |
| * * These tests should document the issue clearly enough that the test can |
| * be condidently removed when the issue is solved in HLJS. |
| * * These tests should rewrite the line of code to have the same number of |
| * characters. This method rewrites the string that gets parsed, but NOT |
| * the string that gets displayed and highlighted. Thus, the positions |
| * must be consistent. |
| * |
| * @param language The name of the HLJS language plugin in use. |
| * @param line The line of code to potentially rewrite. |
| * @return A potentially-rewritten line of code. |
| */ |
| _workaround(language: string, line: string) { |
| if (language === 'cpp') { |
| /** |
| * Prevent confusing < and << operators for the start of a meta string |
| * by converting them to a different operator. |
| * {@see Issue 4864} |
| * {@see https://github.com/isagalaev/highlight.js/issues/1341} |
| */ |
| if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) { |
| line = line.replace(GLOBAL_LT_PATTERN, '|'); |
| } |
| |
| /** |
| * Rewrite CPP wchar_t characters literals to wchar_t string literals |
| * because HLJS only understands the string form. |
| * {@see Issue 5242} |
| * {#see https://github.com/isagalaev/highlight.js/issues/1412} |
| */ |
| if (CPP_WCHAR_PATTERN.test(line)) { |
| line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."'); |
| } |
| |
| return line; |
| } |
| |
| /** |
| * Prevent confusing the closing paren of a parameterized Java annotation |
| * being applied to a formal argument as the closing paren of the argument |
| * list. Rewrite the parens as spaces. |
| * {@see Issue 4776} |
| * {@see https://github.com/isagalaev/highlight.js/issues/1324} |
| */ |
| if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) { |
| return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 '); |
| } |
| |
| /** |
| * HLJS misunderstands backslash character literals in Go. |
| * {@see Issue 5007} |
| * {#see https://github.com/isagalaev/highlight.js/issues/1411} |
| */ |
| if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) { |
| return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"'); |
| } |
| |
| return line; |
| } |
| |
| /** |
| * Tells whether the state has exhausted its current section. |
| */ |
| _isSectionDone(state: SyntaxLayerState) { |
| if (!this.diff) return true; |
| const section = this.diff.content[state.sectionIndex]; |
| if (section.ab) { |
| return state.lineIndex >= section.ab.length; |
| } else { |
| return ( |
| (!section.a || state.lineIndex >= section.a.length) && |
| (!section.b || state.lineIndex >= section.b.length) |
| ); |
| } |
| } |
| |
| /** |
| * For a given state, notify layer listeners of any processed line ranges |
| * that have not yet been notified. |
| */ |
| _notify(state: SyntaxLayerState) { |
| if (state.lineNums.left - state.lastNotify.left) { |
| this._notifyRange(state.lastNotify.left, state.lineNums.left, Side.LEFT); |
| state.lastNotify.left = state.lineNums.left; |
| } |
| if (state.lineNums.right - state.lastNotify.right) { |
| this._notifyRange( |
| state.lastNotify.right, |
| state.lineNums.right, |
| Side.RIGHT |
| ); |
| state.lastNotify.right = state.lineNums.right; |
| } |
| } |
| |
| _notifyRange(start: number, end: number, side: Side) { |
| for (const listener of this._listeners) { |
| listener(start, end, side); |
| } |
| } |
| |
| _loadHLJS() { |
| return this.$.libLoader.getHLJS().then(hljs => { |
| this._hljs = hljs; |
| }); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-syntax-layer': GrSyntaxLayer; |
| } |
| } |