Path: blob/main/extensions/copilot/src/extension/linkify/common/linkifier.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../util/vs/base/common/cancellation';6import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';7import { escapeRegExpCharacters } from '../../../util/vs/base/common/strings';8import { LinkifiedPart, LinkifiedText, coalesceParts } from './linkifiedText';9import type { IContributedLinkifier, ILinkifier, LinkifierContext } from './linkifyService';1011namespace LinkifierState {12export enum Type {13Default,14CodeOrMathBlock,15Accumulating,16}1718export enum AccumulationType {19Word,20InlineCodeOrMath,21PotentialLink,22}2324export const Default = { type: Type.Default } as const;2526export class CodeOrMathBlock {27readonly type = Type.CodeOrMathBlock;2829constructor(30public readonly fence: string,31public readonly indent: string,32public readonly contents = '',33) { }3435appendContents(text: string): CodeOrMathBlock {36return new CodeOrMathBlock(this.fence, this.indent, this.contents + text);37}38}3940export class Accumulating {41readonly type = LinkifierState.Type.Accumulating;4243constructor(44public readonly pendingText: string,45public readonly accumulationType = LinkifierState.AccumulationType.Word,46public readonly terminator?: string,47) { }4849append(text: string): Accumulating {50return new Accumulating(this.pendingText + text, this.accumulationType, this.terminator);51}52}5354export type State = typeof Default | CodeOrMathBlock | Accumulating;55}5657/**58* Stateful linkifier that incrementally linkifies appended text.59*60* Make sure to create a new linkifier for each response.61*/62export class Linkifier implements ILinkifier {6364private _state: LinkifierState.State = LinkifierState.Default;65private _appliedText = '';6667private _totalAddedLinkCount = 0;6869constructor(70private readonly context: LinkifierContext,71private readonly productUriScheme: string,72private readonly linkifiers: readonly IContributedLinkifier[] = [],73) { }7475get totalAddedLinkCount(): number {76return this._totalAddedLinkCount;77}7879async append(newText: string, token: CancellationToken): Promise<LinkifiedText> {80// Linkification needs to run on whole sequences of characters. However the incoming stream may be broken up.81// To handle this, accumulate text until we have whole tokens.8283const out: LinkifiedPart[] = [];8485for (const part of newText.split(/(\s+)/)) {86if (!part.length) {87continue;88}8990switch (this._state.type) {91case LinkifierState.Type.Default: {92if (/^\s+$/.test(part)) {93out.push(this.doAppend(part));94} else {95// Start accumulating9697// `text...98if (/^[^\[`]*`[^`]*$/.test(part)) {99this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.InlineCodeOrMath, '`');100}101// `text`102else if (/^`[^`]+`$/.test(part)) {103// No linkifying inside inline code104out.push(...(await this.doLinkifyAndAppend(part, { skipUnlikify: true }, token)).parts);105}106// $text...107else if (/^[^\[`]*\$[^\$]*$/.test(part)) {108this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.InlineCodeOrMath, '$');109}110// $text$111else if (/^[^\[`]*\$[^\$]*\$$/.test(part)) {112// No linkifying inside math code113out.push(this.doAppend(part));114}115// [text...116else if (/^\s*\[[^\]]*$/.test(part)) {117this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.PotentialLink);118}119// Plain old word120else {121this._state = new LinkifierState.Accumulating(part);122}123}124break;125}126case LinkifierState.Type.CodeOrMathBlock: {127if (128new RegExp('(^|\\n)' + escapeRegExpCharacters(this._state.fence) + '($|\\n)').test(part)129|| (this._state.contents.length > 2 && new RegExp('(^|\\n)\\s*' + escapeRegExpCharacters(this._state.fence) + '($|\\n\\s*$)').test(this._appliedText + part))130) {131// To end the code block, the previous text needs to be empty up the start of the last line and132// at lower indentation than the opening code block.133const indent = this._appliedText.match(/(\n|^)([ \t]*)[`~]*$/);134if (indent && indent[2].length <= this._state.indent.length) {135this._state = LinkifierState.Default;136out.push(this.doAppend(part));137break;138}139}140141this._state = this._state.appendContents(part);142143// No linkifying inside code blocks144out.push(this.doAppend(part));145break;146}147case LinkifierState.Type.Accumulating: {148const completeWord = async (state: LinkifierState.Accumulating, inPart: string, skipUnlikify: boolean) => {149const toAppend = state.pendingText + inPart;150this._state = LinkifierState.Default;151const r = await this.doLinkifyAndAppend(toAppend, { skipUnlikify }, token);152out.push(...r.parts);153};154155if (this._state.accumulationType === LinkifierState.AccumulationType.PotentialLink) {156if (/]/.test(part)) {157this._state = this._state.append(part);158break;159} else if (/\n/.test(part)) {160await completeWord(this._state, part, false);161break;162}163} else if (this._state.accumulationType === LinkifierState.AccumulationType.InlineCodeOrMath && new RegExp(escapeRegExpCharacters(this._state.terminator ?? '`')).test(part)) {164const terminator = this._state.terminator ?? '`';165const terminalIndex = part.indexOf(terminator);166if (terminalIndex === -1) {167await completeWord(this._state, part, true);168} else {169if (terminator === '`') {170await completeWord(this._state, part, true);171} else {172// Math shouldn't run linkifies173174const pre = part.slice(0, terminalIndex + terminator.length);175// No linkifying inside inline math176out.push(this.doAppend(this._state.pendingText + pre));177178// But we can linkify after179const rest = part.slice(terminalIndex + terminator.length);180this._state = LinkifierState.Default;181if (rest.length) {182out.push(...(await this.doLinkifyAndAppend(rest, { skipUnlikify: true }, token)).parts);183}184}185}186break;187} else if (this._state.accumulationType === LinkifierState.AccumulationType.Word && /\s/.test(part)) {188const toAppend = this._state.pendingText + part;189this._state = LinkifierState.Default;190191// Check if we've found special tokens192const fence = toAppend.match(/(^|\n)\s*(`{3,}|~{3,}|\$\$)/);193if (fence) {194const indent = this._appliedText.match(/(\n|^)([ \t]*)$/);195this._state = new LinkifierState.CodeOrMathBlock(fence[2], indent?.[2] ?? '');196out.push(this.doAppend(toAppend));197}198else {199const r = await this.doLinkifyAndAppend(toAppend, {}, token);200out.push(...r.parts);201}202203break;204}205206// Keep accumulating207this._state = this._state.append(part);208break;209}210}211}212return { parts: coalesceParts(out) };213}214215async flush(token: CancellationToken): Promise<LinkifiedText | undefined> {216let out: LinkifiedText | undefined;217218switch (this._state.type) {219case LinkifierState.Type.CodeOrMathBlock: {220out = { parts: [this.doAppend(this._state.contents)] };221break;222}223case LinkifierState.Type.Accumulating: {224const toAppend = this._state.pendingText;225out = await this.doLinkifyAndAppend(toAppend, {}, token);226break;227}228}229230this._state = LinkifierState.Default;231return out;232}233234private doAppend(newText: string): string {235this._appliedText = this._appliedText + newText;236return newText;237}238239private async doLinkifyAndAppend(newText: string, options: { skipUnlikify?: boolean }, token: CancellationToken): Promise<LinkifiedText> {240if (newText.length === 0) {241return { parts: [] };242}243244this.doAppend(newText);245246// Run contributed linkifiers247let parts: LinkifiedPart[] = [newText];248for (const linkifier of this.linkifiers) {249parts = coalesceParts(await this.runLinkifier(parts, linkifier, token));250if (token.isCancellationRequested) {251throw new CancellationError();252}253}254255// Do a final pass that un-linkifies any file links that don't have a scheme.256// This prevents links like: [some text](index.html) from sneaking through as these can never be opened properly.257if (!options.skipUnlikify) {258parts = parts.map(part => {259if (typeof part === 'string') {260return part.replaceAll(/\[([^\[\]]+)\]\(([^\s\)]+)\)/g, (matched, text, path) => {261// Always preserve product URI scheme links262if (path.startsWith(this.productUriScheme + ':')) {263return matched;264}265266return /^\w{2,}:/.test(path) ? matched : text;267});268}269return part;270});271}272273this._totalAddedLinkCount += parts.filter(part => typeof part !== 'string').length;274return { parts };275}276277private async runLinkifier(parts: readonly LinkifiedPart[], linkifier: IContributedLinkifier, token: CancellationToken): Promise<LinkifiedPart[]> {278const out: LinkifiedPart[] = [];279for (const part of parts) {280if (token.isCancellationRequested) {281throw new CancellationError();282}283284if (typeof part === 'string') {285let linkified: LinkifiedText | undefined;286try {287linkified = await linkifier.linkify(part, this.context, token);288} catch (e) {289if (!isCancellationError(e)) {290console.error(e);291}292out.push(part);293continue;294}295296if (linkified) {297out.push(...linkified.parts);298} else {299out.push(part);300}301} else {302out.push(part);303}304}305return out;306}307}308309310