Use jsx to highlight .tsx files until .tsx is officially supported by highlight.js. Change-Id: Id3c354d32803acb20e2ae72497f3bfd833280290
612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
/**
|
|
* @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 {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
|
|
import {DiffFileMetaInfo, DiffInfo} from '../../../types/common';
|
|
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;
|
|
}
|
|
}
|