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
118 changes: 118 additions & 0 deletions src/formatters/formatter-issue920.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
return Promise.resolve("");
}

protected promptForMathValue(): Promise<string> {
return Promise.resolve(this.mathResponse);
}

protected getMacroValue(): string {
return "";
}

protected promptForVariable(): Promise<string> {
return Promise.resolve("");
}

protected getTemplateContent(): Promise<string> {
return Promise.resolve("");
}

protected getSelectedText(): Promise<string> {
return Promise.resolve("");
}

protected getClipboardContent(): Promise<string> {
return Promise.resolve("");
}

protected isTemplatePropertyTypesEnabled(): boolean {
return false;
}

public async testFormat(input: string): Promise<string> {
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");
});
});

36 changes: 26 additions & 10 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,24 @@ export abstract class Formatter {
protected async replaceValueInString(input: string): Promise<string> {
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;
}

Expand Down Expand Up @@ -358,13 +366,21 @@ export abstract class Formatter {
protected abstract promptForMathValue(): Promise<string>;

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;
}

Expand Down