Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/autolinks/__tests__/autolinks.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as assert from 'assert';
import { suite, test } from 'mocha';
import { map } from '../../system/iterable';
import type { Autolink, RefSet } from '../autolinks';
import { getAutolinks, getBranchAutolinks } from '../autolinks';
import type { Autolink, RefSet } from '../autolinks.utils';
import { getAutolinks, getBranchAutolinks } from '../autolinks.utils';

const mockRefSets = (prefixes: string[] = ['']): RefSet[] =>
prefixes.map(prefix => [
Expand Down
291 changes: 18 additions & 273 deletions src/autolinks/autolinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,144 +4,37 @@ import { GlyphChars } from '../constants';
import type { IntegrationId } from '../constants.integrations';
import { IssueIntegrationId } from '../constants.integrations';
import type { Container } from '../container';
import type { IssueOrPullRequest } from '../git/models/issue';
import type { GitRemote } from '../git/models/remote';
import type { ProviderReference } from '../git/models/remoteProvider';
import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/utils/icons';
import type { HostingIntegration, IssueIntegration, ResourceDescriptor } from '../plus/integrations/integration';
import type { HostingIntegration, IssueIntegration } from '../plus/integrations/integration';
import { fromNow } from '../system/date';
import { debug } from '../system/decorators/log';
import { encodeUrl } from '../system/encoding';
import { join, map } from '../system/iterable';
import { Logger } from '../system/logger';
import { escapeMarkdown } from '../system/markdown';
import type { MaybePausedResult } from '../system/promise';
import { getSettledValue, isPromise } from '../system/promise';
import { capitalize, encodeHtmlWeak, escapeRegex, getSuperscript } from '../system/string';
import { capitalize, encodeHtmlWeak, getSuperscript } from '../system/string';
import { configuration } from '../system/vscode/configuration';
import type {
Autolink,
CacheableAutolinkReference,
DynamicAutolinkReference,
EnrichedAutolink,
MaybeEnrichedAutolink,
RefSet,
} from './autolinks.utils';
import {
ensureCachedRegex,
getAutolinks,
getBranchAutolinks,
isDynamic,
numRegex,
supportedAutolinkIntegrations,
} from './autolinks.utils';

const emptyAutolinkMap = Object.freeze(new Map<string, Autolink>());

const numRegex = /<num>/g;

export type AutolinkType = 'issue' | 'pullrequest';
export type AutolinkReferenceType = 'commit' | 'branch';

export interface AutolinkReference {
/** Short prefix to match to generate autolinks for the external resource */
readonly prefix: string;
/** URL of the external resource to link to */
readonly url: string;
/** Whether alphanumeric characters should be allowed in `<num>` */
readonly alphanumeric: boolean;
/** Whether case should be ignored when matching the prefix */
readonly ignoreCase: boolean;
readonly title: string | undefined;

readonly type?: AutolinkType;
readonly referenceType?: AutolinkReferenceType;
readonly description?: string;
readonly descriptor?: ResourceDescriptor;
}

export interface Autolink extends AutolinkReference {
provider?: ProviderReference;
id: string;
index?: number;

tokenize?:
| ((
text: string,
outputFormat: 'html' | 'markdown' | 'plaintext',
tokenMapping: Map<string, string>,
enrichedAutolinks?: Map<string, MaybeEnrichedAutolink>,
prs?: Set<string>,
footnotes?: Map<number, string>,
) => string)
| null;
}

export type EnrichedAutolink = [
issueOrPullRequest: Promise<IssueOrPullRequest | undefined> | undefined,
autolink: Autolink,
];

export type MaybeEnrichedAutolink = readonly [
issueOrPullRequest: MaybePausedResult<IssueOrPullRequest | undefined> | undefined,
autolink: Autolink,
];

export function serializeAutolink(value: Autolink): Autolink {
const serialized: Autolink = {
provider: value.provider
? {
id: value.provider.id,
name: value.provider.name,
domain: value.provider.domain,
icon: value.provider.icon,
}
: undefined,
id: value.id,
index: value.index,
prefix: value.prefix,
url: value.url,
alphanumeric: value.alphanumeric,
ignoreCase: value.ignoreCase,
title: value.title,
type: value.type,
description: value.description,
descriptor: value.descriptor,
};
return serialized;
}

export interface CacheableAutolinkReference extends AutolinkReference {
tokenize?:
| ((
text: string,
outputFormat: 'html' | 'markdown' | 'plaintext',
tokenMapping: Map<string, string>,
enrichedAutolinks?: Map<string, MaybeEnrichedAutolink>,
prs?: Set<string>,
footnotes?: Map<number, string>,
) => string)
| null;

messageHtmlRegex?: RegExp;
messageMarkdownRegex?: RegExp;
messageRegex?: RegExp;
branchNameRegex?: RegExp;
}

export interface DynamicAutolinkReference {
tokenize?:
| ((
text: string,
outputFormat: 'html' | 'markdown' | 'plaintext',
tokenMapping: Map<string, string>,
enrichedAutolinks?: Map<string, MaybeEnrichedAutolink>,
prs?: Set<string>,
footnotes?: Map<number, string>,
) => string)
| null;
parse: (text: string, autolinks: Map<string, Autolink>) => void;
}

export const supportedAutolinkIntegrations = [IssueIntegrationId.Jira];

function isDynamic(ref: AutolinkReference | DynamicAutolinkReference): ref is DynamicAutolinkReference {
return !('prefix' in ref) && !('url' in ref);
}

function isCacheable(ref: AutolinkReference | DynamicAutolinkReference): ref is CacheableAutolinkReference {
return 'prefix' in ref && ref.prefix != null && 'url' in ref && ref.url != null;
}

export type RefSet = [
ProviderReference | undefined,
(AutolinkReference | DynamicAutolinkReference)[] | CacheableAutolinkReference[],
];

export class Autolinks implements Disposable {
protected _disposable: Disposable | undefined;
private _references: CacheableAutolinkReference[] = [];
Expand Down Expand Up @@ -629,151 +522,3 @@ export class Autolinks implements Disposable {
return true;
}
}

function ensureCachedRegex(
ref: CacheableAutolinkReference,
outputFormat: 'html',
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageHtmlRegex'>;
function ensureCachedRegex(
ref: CacheableAutolinkReference,
outputFormat: 'markdown',
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageMarkdownRegex'>;
function ensureCachedRegex(
ref: CacheableAutolinkReference,
outputFormat: 'plaintext',
): asserts ref is RequireSome<CacheableAutolinkReference, 'messageRegex' | 'branchNameRegex'>;
function ensureCachedRegex(ref: CacheableAutolinkReference, outputFormat: 'html' | 'markdown' | 'plaintext') {
// Regexes matches the ref prefix followed by a token (e.g. #1234)
if (outputFormat === 'markdown' && ref.messageMarkdownRegex == null) {
// Extra `\\\\` in `\\\\\\[` is because the markdown is escaped
ref.messageMarkdownRegex = new RegExp(
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(escapeMarkdown(ref.prefix)))}(${
ref.alphanumeric ? '\\w' : '\\d'
}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
} else if (outputFormat === 'html' && ref.messageHtmlRegex == null) {
ref.messageHtmlRegex = new RegExp(
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(encodeHtmlWeak(ref.prefix))}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
} else if (ref.messageRegex == null) {
ref.messageRegex = new RegExp(
`(^|\\s|\\(|\\[|\\{)(${escapeRegex(ref.prefix)}(${ref.alphanumeric ? '\\w' : '\\d'}+))\\b`,
ref.ignoreCase ? 'gi' : 'g',
);
ref.branchNameRegex = new RegExp(
`(^|\\-|_|\\.|\\/)(?<prefix>${ref.prefix})(?<issueKeyNumber>${
ref.alphanumeric ? '\\w' : '\\d'
}+)(?=$|\\-|_|\\.|\\/)`,
'gi',
);
}

return true;
}

/**
* Compares autolinks
* @returns non-0 result that means a probability of the autolink `b` is more relevant of the autolink `a`
*/
function compareAutolinks(a: Autolink, b: Autolink): number {
// consider that if the number is in the start, it's the most relevant link
if (b.index === 0) return 1;
if (a.index === 0) return -1;

// maybe it worths to use some weight function instead.
return (
b.prefix.length - a.prefix.length ||
b.id.length - a.id.length ||
(b.index != null && a.index != null ? -(b.index - a.index) : 0)
);
}

export function getAutolinks(message: string, refsets: Readonly<RefSet[]>) {
const autolinks = new Map<string, Autolink>();

let match;
let num;
for (const [provider, refs] of refsets) {
for (const ref of refs) {
if (!isCacheable(ref) || (ref.referenceType && ref.referenceType !== 'commit')) {
if (isDynamic(ref)) {
ref.parse(message, autolinks);
}
continue;
}

ensureCachedRegex(ref, 'plaintext');

do {
match = ref.messageRegex.exec(message);
if (!match) break;

[, , , num] = match;

autolinks.set(num, {
provider: provider,
id: num,
index: match.index,
prefix: ref.prefix,
url: ref.url?.replace(numRegex, num),
alphanumeric: ref.alphanumeric,
ignoreCase: ref.ignoreCase,
title: ref.title?.replace(numRegex, num),
type: ref.type,
description: ref.description?.replace(numRegex, num),
descriptor: ref.descriptor,
});
} while (true);
}
}

return autolinks;
}

export function getBranchAutolinks(branchName: string, refsets: Readonly<RefSet[]>) {
const autolinks = new Map<string, Autolink>();

let match;
let num;
for (const [provider, refs] of refsets) {
for (const ref of refs) {
if (
!isCacheable(ref) ||
ref.type === 'pullrequest' ||
(ref.referenceType && ref.referenceType !== 'branch')
) {
continue;
}

ensureCachedRegex(ref, 'plaintext');
const matches = branchName.matchAll(ref.branchNameRegex);
do {
match = matches.next();
if (!match.value?.groups) break;

num = match?.value?.groups.issueKeyNumber;
let index = match.value.index;
const linkUrl = ref.url?.replace(numRegex, num);
// strange case (I would say synthetic), but if we parse the link twice, use the most relevant of them
const existingIndex = autolinks.get(linkUrl)?.index;
if (existingIndex != null) {
index = Math.min(index, existingIndex);
}
autolinks.set(linkUrl, {
...ref,
provider: provider,
id: num,
index: index,
url: linkUrl,
title: ref.title?.replace(numRegex, num),
description: ref.description?.replace(numRegex, num),
descriptor: ref.descriptor,
});
} while (!match.done);
}
}

return new Map([...autolinks.entries()].sort((a, b) => compareAutolinks(a[1], b[1])));
}
Loading
Loading