blob: b5b90254f57baed186627bd18827caa1ac44b13c [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import 'ba-linkify/ba-linkify';
import {CommentLinkInfo, CommentLinks} from '../types/common';
import {getBaseUrl} from './url-util';
/**
* Finds links within the base string and convert them to HTML. Config-based
* rewrites are only applied on text that is not linked by the default linking
* library.
*/
export function linkifyUrlsAndApplyRewrite(
base: string,
repoCommentLinks: CommentLinks
): string {
const parts: string[] = [];
window.linkify(insertZeroWidthSpace(base), {
callback: (text, href) => {
if (href) {
parts.push(removeZeroWidthSpace(createLinkTemplate(href, text)));
} else {
const rewriteResults = getRewriteResultsFromConfig(
text,
repoCommentLinks
);
parts.push(removeZeroWidthSpace(applyRewrites(text, rewriteResults)));
}
},
});
return parts.join('');
}
/**
* Generates a list of rewrites that would be applied to a base string. They are
* not applied immediately to the base text because one rewrite may interfere or
* overlap with a later rewrite. Only after all rewrites are known they are
* carefully merged with `applyRewrites`.
*/
function getRewriteResultsFromConfig(
base: string,
repoCommentLinks: CommentLinks
): RewriteResult[] {
const enabledRewrites = Object.values(repoCommentLinks).filter(
commentLinkInfo =>
commentLinkInfo.enabled !== false &&
(commentLinkInfo.link !== undefined || commentLinkInfo.html !== undefined)
);
return enabledRewrites.flatMap(rewrite => {
const regexp = new RegExp(rewrite.match, 'g');
const partialResults: RewriteResult[] = [];
let match: RegExpExecArray | null;
while ((match = regexp.exec(base)) !== null) {
const fullReplacementText = getReplacementText(match[0], rewrite);
// The replacement may not be changing the entire matched substring so we
// "trim" the replacement position and text to the part that is actually
// different. This makes sure that unchanged portions are still eligible
// for other rewrites without being rejected as overlaps during
// `applyRewrites`. The new `replacementText` is not eligible for other
// rewrites since it would introduce unexpected interactions between
// rewrites depending on their order of definition/execution.
const sharedPrefixLength = getSharedPrefixLength(
match[0],
fullReplacementText
);
const sharedSuffixLength = getSharedSuffixLength(
match[0],
fullReplacementText
);
const prefixIndex = sharedPrefixLength;
const matchSuffixIndex = match[0].length - sharedSuffixLength;
const fullReplacementSuffixIndex =
fullReplacementText.length - sharedSuffixLength;
partialResults.push({
replacedTextStartPosition: match.index + prefixIndex,
replacedTextEndPosition: match.index + matchSuffixIndex,
replacementText: fullReplacementText.substring(
prefixIndex,
fullReplacementSuffixIndex
),
});
}
return partialResults;
});
}
/**
* Applies all the rewrites to the given base string. To resolve cases where
* multiple rewrites target overlapping pieces of the base string, the rewrite
* that ends latest is kept and the rest are not applied and discarded.
*/
function applyRewrites(base: string, rewriteResults: RewriteResult[]): string {
const rewritesByEndPosition = [...rewriteResults].sort((a, b) => {
if (b.replacedTextEndPosition !== a.replacedTextEndPosition) {
return b.replacedTextEndPosition - a.replacedTextEndPosition;
}
return a.replacedTextStartPosition - b.replacedTextStartPosition;
});
const filteredSortedRewrites: RewriteResult[] = [];
let latestReplace = base.length;
for (const rewrite of rewritesByEndPosition) {
// Only accept rewrites that do not overlap with any previously accepted
// rewrites.
if (rewrite.replacedTextEndPosition <= latestReplace) {
filteredSortedRewrites.push(rewrite);
latestReplace = rewrite.replacedTextStartPosition;
}
}
return filteredSortedRewrites.reduce(
(text, rewrite) =>
text
.substring(0, rewrite.replacedTextStartPosition)
.concat(rewrite.replacementText)
.concat(text.substring(rewrite.replacedTextEndPosition)),
base
);
}
/**
* For a given regexp match, apply the rewrite based on the rewrite's type and
* return the resulting string.
*/
function getReplacementText(
matchedText: string,
rewrite: CommentLinkInfo
): string {
if (rewrite.link !== undefined) {
const replacementHref = rewrite.link.startsWith('/')
? `${getBaseUrl()}${rewrite.link}`
: rewrite.link;
const regexp = new RegExp(rewrite.match, 'g');
return matchedText.replace(
regexp,
createLinkTemplate(
replacementHref,
rewrite.text ?? '$&',
rewrite.prefix,
rewrite.suffix
)
);
} else if (rewrite.html !== undefined) {
return matchedText.replace(new RegExp(rewrite.match, 'g'), rewrite.html);
} else {
throw new Error('commentLinkInfo is not a link or html rewrite');
}
}
/**
* Some tools are known to look for reviewers/CCs by finding lines such as
* "R=foo@gmail.com, bar@gmail.com". However, "=" is technically a valid email
* character, so ba-linkify interprets the entire string "R=foo@gmail.com" as an
* email address. To fix this, we insert a zero width space character \u200B
* before linking that prevents ba-linkify from associating the prefix with the
* email. After linking we remove the zero width space.
*/
function insertZeroWidthSpace(base: string) {
return base.replace(/^(R=|CC=)/g, '$&\u200B');
}
function removeZeroWidthSpace(base: string) {
return base.replace(/\u200B/g, '');
}
function createLinkTemplate(
href: string,
displayText: string,
prefix?: string,
suffix?: string
) {
return `${
prefix ?? ''
}<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
suffix ?? ''
}`;
}
/**
* Returns the number of characters that are identical at the start of both
* strings.
*
* For example, `getSharedPrefixLength('12345678', '1234zz78')` would return 4
*/
function getSharedPrefixLength(a: string, b: string) {
let i = 0;
for (; i < a.length && i < b.length; ++i) {
if (a[i] !== b[i]) {
return i;
}
}
return i;
}
/**
* Returns the number of characters that are identical at the end of both
* strings.
*
* For example, `getSharedSuffixLength('12345678', '1234zz78')` would return 2
*/
function getSharedSuffixLength(a: string, b: string) {
let i = a.length;
for (let j = b.length; i !== 0 && j !== 0; --i, --j) {
if (a[i] !== b[j]) {
return a.length - 1 - i;
}
}
return a.length - i;
}
interface RewriteResult {
replacedTextStartPosition: number;
replacedTextEndPosition: number;
replacementText: string;
}