diff --git a/src/formatters/formatter-issue920.test.ts b/src/formatters/formatter-issue920.test.ts new file mode 100644 index 00000000..65dfa0a4 --- /dev/null +++ b/src/formatters/formatter-issue920.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Formatter } from "./formatter"; + +// Regression tests for issue #920: +// - Entering `{{value}}` (or text containing it) into the VALUE prompt caused an infinite loop. +// - Entering `{{mvalue}}` into the math modal caused repeated prompting / non-termination. +class Issue920TestFormatter extends Formatter { + private valueResponse = ""; + private mathResponse = ""; + + constructor() { + super(); + } + + public setValueResponse(value: string): void { + this.valueResponse = value; + } + + public setMathResponse(value: string): void { + this.mathResponse = value; + } + + protected async format(input: string): Promise { + let output = input; + output = await this.replaceValueInString(output); + output = await this.replaceMathValueInString(output); + return output; + } + + protected promptForValue(): string { + return this.valueResponse; + } + + protected getCurrentFileLink(): string | null { + return null; + } + + protected getCurrentFileName(): string | null { + return null; + } + + protected getVariableValue(variableName: string): string { + return (this.variables.get(variableName) as string) ?? ""; + } + + protected suggestForValue(): string { + return ""; + } + + protected suggestForField(): Promise { + return Promise.resolve(""); + } + + protected promptForMathValue(): Promise { + return Promise.resolve(this.mathResponse); + } + + protected getMacroValue(): string { + return ""; + } + + protected promptForVariable(): Promise { + return Promise.resolve(""); + } + + protected getTemplateContent(): Promise { + return Promise.resolve(""); + } + + protected getSelectedText(): Promise { + return Promise.resolve(""); + } + + protected getClipboardContent(): Promise { + return Promise.resolve(""); + } + + protected isTemplatePropertyTypesEnabled(): boolean { + return false; + } + + public async testFormat(input: string): Promise { + return await this.format(input); + } +} + +describe("Issue #920: VALUE/MVALUE self-references should not hang", () => { + let formatter: Issue920TestFormatter; + + beforeEach(() => { + formatter = new Issue920TestFormatter(); + }); + + it("treats {{VALUE}} returned from the VALUE prompt as literal (no recursion)", async () => { + formatter.setValueResponse("{{VALUE}}"); + const result = await formatter.testFormat("Start {{VALUE}} End"); + expect(result).toBe("Start {{VALUE}} End"); + }); + + it("does not recursively expand {{VALUE}} inside user-provided VALUE input", async () => { + formatter.setValueResponse("prefix {{VALUE}}"); + const result = await formatter.testFormat("A {{VALUE}} B {{VALUE}} C"); + expect(result).toBe("A prefix {{VALUE}} B prefix {{VALUE}} C"); + }); + + it("treats {{MVALUE}} returned from the math prompt as literal (no recursion)", async () => { + formatter.setMathResponse("{{MVALUE}}"); + const result = await formatter.testFormat("Start {{MVALUE}} End"); + expect(result).toBe("Start {{MVALUE}} End"); + }); + + it("does not recursively expand {{MVALUE}} inside user-provided math input", async () => { + formatter.setMathResponse("prefix {{MVALUE}}"); + const result = await formatter.testFormat("A {{MVALUE}} B"); + expect(result).toBe("A prefix {{MVALUE}} B"); + }); +}); + diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 147ba7b1..0b1d6b12 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -130,16 +130,24 @@ export abstract class Formatter { protected async replaceValueInString(input: string): Promise { let output: string = input; - if (this.variables.has("value")) { - this.value = this.variables.get("value") as string; - } + // Fast path: nothing to do. + if (!NAME_VALUE_REGEX.test(output)) return output; - while (NAME_VALUE_REGEX.test(output)) { - if (!this.value) this.value = await this.promptForValue(); + // Preserve programmatic VALUE injection via reserved variable name `value`. + if (this.hasConcreteVariable("value")) { + this.value = String(this.variables.get("value")); + } - output = this.replacer(output, NAME_VALUE_REGEX, this.value); + // Prompt only once per formatter run (empty string is a valid value). + if (this.value === undefined) { + this.value = await this.promptForValue(); } + // Replace all occurrences in a single non-recursive pass. + // Important: use a replacer function so `$` in user input is treated literally. + const regex = new RegExp(NAME_VALUE_REGEX.source, "gi"); + output = output.replace(regex, () => this.value); + return output; } @@ -358,13 +366,21 @@ export abstract class Formatter { protected abstract promptForMathValue(): Promise; protected async replaceMathValueInString(input: string) { - let output: string = input; + // Build the output by scanning the current input once. + // This avoids infinite replacement loops when the provided math input contains {{MVALUE}}. + const regex = new RegExp(MATH_VALUE_REGEX.source, "gi"); + + let output = ""; + let lastIndex = 0; + let match: RegExpExecArray | null; - while (MATH_VALUE_REGEX.test(output)) { - const mathstr = await this.promptForMathValue(); - output = this.replacer(output, MATH_VALUE_REGEX, mathstr); + while ((match = regex.exec(input)) !== null) { + output += input.slice(lastIndex, match.index); + output += await this.promptForMathValue(); + lastIndex = match.index + match[0].length; } + output += input.slice(lastIndex); return output; }