From 0a16429bb6b8b90cc172bd2438a8736fecd84d42 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 16:58:12 +0000 Subject: [PATCH 1/5] feat(format): mimic option-list spacing in format:inline VALUE |format:inline joins picks with ", " when every option-list comma is followed by horizontal whitespace. Mixed lists, compact lists, and FIELD/FILE inline stay ",". Fixes #1701 Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/FormatSyntax.md | 5 +- src/formatters/completeFormatter.test.ts | 11 +++ src/formatters/formatter.ts | 9 +- src/utils/FieldSuggestionParser.test.ts | 2 +- src/utils/FieldSuggestionParser.ts | 7 +- src/utils/fileSyntax.test.ts | 6 +- src/utils/fileSyntax.ts | 10 ++- src/utils/multiValueFormat.test.ts | 23 +++-- src/utils/multiValueFormat.ts | 52 +++++++++--- src/utils/valueSyntax.test.ts | 33 +++++++- src/utils/valueSyntax.ts | 97 ++++++++++++++++------ 11 files changed, 197 insertions(+), 58 deletions(-) diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index 4ab19ba3..fd35172e 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -401,8 +401,9 @@ from where the placeholder appears: - `|format:markdown` writes a vertical Markdown bullet list. Put the placeholder on its own line: `{{VALUE:Alpha,Beta|multi|format:markdown}}` becomes `- Alpha` followed by `- Beta`. -- `|format:inline` always writes the existing comma-separated text form: - `Alpha,Beta`. +- `|format:inline` writes comma-separated text. VALUE mimics the option-list + comma spacing. Space after every comma, or none. Mixed lists stay compact. + FIELD and FILE have no option list and stay `Alpha,Beta`. - `|format:auto` is the default and preserves the context-sensitive behavior described below. diff --git a/src/formatters/completeFormatter.test.ts b/src/formatters/completeFormatter.test.ts index 19a44384..598eb515 100644 --- a/src/formatters/completeFormatter.test.ts +++ b/src/formatters/completeFormatter.test.ts @@ -1830,6 +1830,17 @@ describe("CompleteFormatter - remote prompt provider routing", () => { ).resolves.toBe(expected); }); + it("renders VALUE inline with a space after each option-list comma", async () => { + const suggesterMulti = vi.fn( + async (_display: string[], _actual: string[]) => ["a", "b"], + ); + const f = providerFormatter({ suggesterMulti }); + + await expect( + f.formatFileContent("{{VALUE:a, b|multi|format:inline}}"), + ).resolves.toBe("a, b"); + }); + it("routes anonymous {{VALUE|type:checkbox}} to the provider's suggester, not the Obsidian modal", async () => { mocks.genericSuggesterSuggest.mockResolvedValue("false"); // modal answer (should be unused) const suggester = vi.fn(async () => "true"); diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 20b8d5e9..4a0b8d02 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -55,7 +55,8 @@ import { } from "../utils/yamlScalarQuoting"; import { renderExplicitMultiValue, - type MultiValueFormat, + resolveMultiValueFormat, + type ResolvedMultiValueFormat, } from "../utils/multiValueFormat"; // |type: overrides whose value is a typed YAML scalar (Number, Boolean): an @@ -1068,7 +1069,7 @@ export abstract class Formatter { rawValue: unknown; fallbackKey: string; heuristicEnabled: boolean; - multiFormat?: MultiValueFormat; + multiFormat?: ResolvedMultiValueFormat; }): string | undefined { if (Array.isArray(args.rawValue) && args.multiFormat) { const explicit = renderExplicitMultiValue({ @@ -1494,7 +1495,9 @@ export abstract class Formatter { rawValue, fallbackKey: parsed.fieldName, heuristicEnabled: false, - multiFormat: parsed.multiFormat ?? "auto", + multiFormat: + parsed.multiFormat ?? + resolveMultiValueFormat("auto"), }) ?? rawValue.join(","); } else { replacement = escapeValueInsideQuotedYamlScalar( diff --git a/src/utils/FieldSuggestionParser.test.ts b/src/utils/FieldSuggestionParser.test.ts index e881b75a..05d6f88b 100644 --- a/src/utils/FieldSuggestionParser.test.ts +++ b/src/utils/FieldSuggestionParser.test.ts @@ -118,7 +118,7 @@ describe("FieldSuggestionParser", () => { "topics|multi|format:markdown", ); expect(result.multiSelect).toBe(true); - expect(result.multiFormat).toBe("markdown"); + expect(result.multiFormat).toEqual({ format: "markdown" }); }); it("warns on |format: without |multi, even |format:auto", () => { diff --git a/src/utils/FieldSuggestionParser.ts b/src/utils/FieldSuggestionParser.ts index c2b589c2..8de60880 100644 --- a/src/utils/FieldSuggestionParser.ts +++ b/src/utils/FieldSuggestionParser.ts @@ -7,7 +7,9 @@ import { import { suggestSimilarKeys } from "./suggestSimilarKeys"; import { parseMultiValueFormat, + resolveMultiValueFormat, type MultiValueFormat, + type ResolvedMultiValueFormat, } from "./multiValueFormat"; /** @@ -167,7 +169,7 @@ export class FieldSuggestionParser { fieldName: string; filters: FieldFilter; multiSelect?: boolean; - multiFormat?: MultiValueFormat; + multiFormat?: ResolvedMultiValueFormat; } { const parts = splitPipeParts(input).map((p) => p.trim()); const fieldName = parts[0]; @@ -322,11 +324,12 @@ export class FieldSuggestionParser { multiFormat = "auto"; } + const resolved = resolveMultiValueFormat(multiFormat); return { fieldName, filters, ...(multiSelect ? { multiSelect } : {}), - ...(multiFormat !== "auto" ? { multiFormat } : {}), + ...(resolved.format !== "auto" ? { multiFormat: resolved } : {}), }; } } diff --git a/src/utils/fileSyntax.test.ts b/src/utils/fileSyntax.test.ts index 6a23d578..d2956c8e 100644 --- a/src/utils/fileSyntax.test.ts +++ b/src/utils/fileSyntax.test.ts @@ -42,14 +42,14 @@ describe("parseFileToken", () => { it("parses multi-select as FILE behavior", () => { const parsed = parseFileToken("People|multi"); expect(parsed?.multiSelect).toBe(true); - expect(parsed?.multiFormat).toBe("auto"); + expect(parsed?.multiFormat).toEqual({ format: "auto" }); expect(parsed?.variableKey).toContain("|multi"); }); it("parses an explicit multi-select format", () => { const parsed = parseFileToken("People|multi|format:yaml"); expect(parsed?.multiSelect).toBe(true); - expect(parsed?.multiFormat).toBe("yaml"); + expect(parsed?.multiFormat).toEqual({ format: "yaml" }); }); it("warns on |format: without |multi, even |format:auto", () => { @@ -57,7 +57,7 @@ describe("parseFileToken", () => { const parsed = parseFileToken("People|format:auto", { warn: (msg) => warnings.push(msg), }); - expect(parsed?.multiFormat).toBe("auto"); + expect(parsed?.multiFormat).toEqual({ format: "auto" }); expect(warnings.some((m) => m.includes("needs |multi"))).toBe(true); }); diff --git a/src/utils/fileSyntax.ts b/src/utils/fileSyntax.ts index b3174bf2..87a8b96e 100644 --- a/src/utils/fileSyntax.ts +++ b/src/utils/fileSyntax.ts @@ -8,7 +8,10 @@ import { parsePipeKeyValue, splitPipeParts, } from "./pipeSyntax"; -import type { MultiValueFormat } from "./multiValueFormat"; +import { + resolveMultiValueFormat, + type ResolvedMultiValueFormat, +} from "./multiValueFormat"; import type { WarnSink } from "./warnSink"; // Namespaces FILE variable values in the variables map, separate from plain @@ -41,7 +44,7 @@ export type ParsedFileToken = { /** Pick several files and store/render them as a list. */ multiSelect: boolean; /** Explicit output shape for a multi-select; auto preserves legacy behavior. */ - multiFormat: MultiValueFormat; + multiFormat: ResolvedMultiValueFormat; /** Variables-map key. Full token identity by default; `|name:` shares it. */ variableKey: string; }; @@ -184,7 +187,8 @@ export function parseFileToken( warn: options?.warn, }); const multiSelect = bareMultiSelect || (fieldParsed.multiSelect ?? false); - const multiFormat = fieldParsed.multiFormat ?? "auto"; + const multiFormat = + fieldParsed.multiFormat ?? resolveMultiValueFormat("auto"); const filter: FieldFilter = { folder: folderPath, tags: fieldParsed.filters.tags, diff --git a/src/utils/multiValueFormat.test.ts b/src/utils/multiValueFormat.test.ts index 3ab7da78..26dd2967 100644 --- a/src/utils/multiValueFormat.test.ts +++ b/src/utils/multiValueFormat.test.ts @@ -26,29 +26,40 @@ describe("multi-select output formatting", () => { input: "{{VALUE:a,b|multi}}", matchStart: 0, values: ["a", "b"], - format: "auto", + format: { format: "auto" }, }), ).toBeUndefined(); }); - it("renders inline output with the legacy comma separator", () => { + it("renders compact inline output", () => { expect( renderExplicitMultiValue({ input: "value", matchStart: 0, values: ["Alpha", "Beta"], - format: "inline", + format: { format: "inline", separator: "," }, }), ).toBe("Alpha,Beta"); }); + it("renders spaced inline output", () => { + expect( + renderExplicitMultiValue({ + input: "value", + matchStart: 0, + values: ["Alpha", "Beta"], + format: { format: "inline", separator: ", " }, + }), + ).toBe("Alpha, Beta"); + }); + it("renders a quoted YAML flow sequence that preserves string values", () => { expect( renderExplicitMultiValue({ input: "topics: token", matchStart: 8, values: ["0042", "a: b", 'quoted "value"'], - format: "yaml", + format: { format: "yaml" }, }), ).toBe('["0042", "a: b", "quoted \\"value\\""]'); }); @@ -59,7 +70,7 @@ describe("multi-select output formatting", () => { input: "topics: token", matchStart: 8, values: [], - format: "yaml", + format: { format: "yaml" }, }), ).toBe("[]"); }); @@ -70,7 +81,7 @@ describe("multi-select output formatting", () => { input: " token", matchStart: 2, values: ["Alpha", "Beta\ncontinued"], - format: "markdown", + format: { format: "markdown" }, }), ).toBe("- Alpha\n - Beta\n continued"); }); diff --git a/src/utils/multiValueFormat.ts b/src/utils/multiValueFormat.ts index 5465deb3..d9a06e1d 100644 --- a/src/utils/multiValueFormat.ts +++ b/src/utils/multiValueFormat.ts @@ -3,6 +3,12 @@ import type { WarnSink } from "./warnSink"; export type MultiValueFormat = "auto" | "inline" | "yaml" | "markdown"; +export type InlineSeparator = "," | ", "; + +export type ResolvedMultiValueFormat = + | { format: "auto" | "yaml" | "markdown" } + | { format: "inline"; separator: InlineSeparator }; + const MULTI_VALUE_FORMATS = new Set([ "auto", "inline", @@ -10,6 +16,23 @@ const MULTI_VALUE_FORMATS = new Set([ "markdown", ]); +export function resolveMultiValueFormat( + format: "inline", + separator: InlineSeparator, +): ResolvedMultiValueFormat; +export function resolveMultiValueFormat( + format: MultiValueFormat, +): ResolvedMultiValueFormat; +export function resolveMultiValueFormat( + format: MultiValueFormat, + separator?: InlineSeparator, +): ResolvedMultiValueFormat { + if (format === "inline") { + return { format: "inline", separator: separator ?? "," }; + } + return { format }; +} + export function parseMultiValueFormat( raw: string, tokenDisplay: string, @@ -43,18 +66,25 @@ export function renderExplicitMultiValue(args: { input: string; matchStart: number; values: readonly unknown[]; - format: MultiValueFormat; + format: ResolvedMultiValueFormat; }): string | undefined { const { input, matchStart, values, format } = args; - if (format === "auto") return undefined; - - const strings = values.map((value) => String(value)); - if (format === "inline") return strings.join(","); - if (format === "yaml") { - return `[${strings.map(quoteYamlDouble).join(", ")}]`; + switch (format.format) { + case "auto": + return undefined; + case "inline": + return values.map((value) => String(value)).join(format.separator); + case "yaml": + return `[${values.map((value) => quoteYamlDouble(String(value))).join(", ")}]`; + case "markdown": { + const strings = values.map((value) => String(value)); + if (strings.length === 0) return ""; + const indent = currentLineIndent(input, matchStart); + return strings.map(renderMarkdownItem).join(`\n${indent}`); + } + default: { + const _exhaustive: never = format; + return _exhaustive; + } } - - if (strings.length === 0) return ""; - const indent = currentLineIndent(input, matchStart); - return strings.map(renderMarkdownItem).join(`\n${indent}`); } diff --git a/src/utils/valueSyntax.test.ts b/src/utils/valueSyntax.test.ts index 9e8bb989..40d16952 100644 --- a/src/utils/valueSyntax.test.ts +++ b/src/utils/valueSyntax.test.ts @@ -257,22 +257,49 @@ describe("parseValueToken", () => { ); expect(parsed?.multiSelect).toBe(true); expect(parsed?.multiEmit).toBe("linklist"); - expect(parsed?.multiFormat).toBe(format); + expect(parsed?.multiFormat).toEqual( + format === "inline" + ? { format: "inline", separator: "," } + : { format }, + ); }, ); it("warns and ignores an explicit format without |multi", () => { const warnSpy = vi.spyOn(log, "logWarning").mockImplementation(() => {}); - expect(parseValueToken("Only|format:yaml")?.multiFormat).toBe("auto"); + expect(parseValueToken("Only|format:yaml")?.multiFormat).toEqual({ + format: "auto", + }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("needs |multi")); }); it("warns on an explicit |format:auto without |multi (a silent no-op otherwise)", () => { const warnSpy = vi.spyOn(log, "logWarning").mockImplementation(() => {}); - expect(parseValueToken("Only|format:auto")?.multiFormat).toBe("auto"); + expect(parseValueToken("Only|format:auto")?.multiFormat).toEqual({ + format: "auto", + }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("needs |multi")); }); + it.each([ + ["option a,option b|multi|format:inline", ","], + ["option a, option b|multi|format:inline", ", "], + ["a, b,c|multi|format:inline", ","], + ["a,b, c|multi|format:inline", ","], + ['"a, b",c|multi|format:inline', ","], + ['"a, b", c|multi|format:inline', ", "], + ["a ,b|multi|format:inline", ","], + ["a,b|multi|format:inline|text:option a, option b", ","], + ] as const)( + "infers the inline separator from %s", + (token, separator) => { + expect(parseValueToken(token)?.multiFormat).toEqual({ + format: "inline", + separator, + }); + }, + ); + it("warns and ignores |multi without an option list", () => { const warnSpy = vi.spyOn(log, "logWarning").mockImplementation(() => {}); expect(parseValueToken("Only|multi")?.multiSelect).toBe(false); diff --git a/src/utils/valueSyntax.ts b/src/utils/valueSyntax.ts index 03fdc508..73676a34 100644 --- a/src/utils/valueSyntax.ts +++ b/src/utils/valueSyntax.ts @@ -8,7 +8,10 @@ import { import { isSupportedCaseStyle, SUPPORTED_CASE_STYLES } from "./caseTransform"; import { parseMultiValueFormat, + resolveMultiValueFormat, + type InlineSeparator, type MultiValueFormat, + type ResolvedMultiValueFormat, } from "./multiValueFormat"; import { NOTICE_WARN, SILENT_WARN, type WarnSink } from "./warnSink"; @@ -93,7 +96,7 @@ export type ParsedValueToken = { /** |multi:linklist wraps each pick as [[name]]; defaults to plain text. */ multiEmit: MultiEmit; /** Explicit output shape for a multi-select; auto preserves legacy behavior. */ - multiFormat: MultiValueFormat; + multiFormat: ResolvedMultiValueFormat; }; export function buildValueVariableKey( @@ -603,29 +606,32 @@ function isDoubleQuote(ch: string | undefined): boolean { // excludes them), but excluding them here keeps the helper safe if reused. const HORIZONTAL_WS = /[^\S\r\n]/; -/** - * Split a comma-separated VALUE option list while honoring double-quoted fields, - * so a comma inside `"..."` stays literal (#239). CSV-style rules: - * - A field is quoted only when it STARTS with a double-quote (after optional - * leading whitespace); a quote anywhere else is literal. - * - Inside a quoted field, `""` is one literal quote and a comma is literal. - * - A closing quote is only honored when the next non-space char is a comma or - * end-of-input (STRICT close). - * - * Any input that is not cleanly quote-balanced — an unterminated quote, or a - * quote "closed" by other text (e.g. `"a"b`) — falls back to a plain comma - * split. That guarantees every token WITHOUT a balanced double-quoted field - * parses byte-identically to the pre-#239 behavior. - * - * Returns raw fields with the surrounding quotes stripped; callers apply the - * usual `.map(trim).filter(Boolean)`. Whitespace inside quotes is therefore not - * preserved — quoting protects commas, which survive the trim. - */ -export function splitQuotedCommaList(input: string): string[] { +type CommaListScan = { + fields: string[]; + delimiterSpaced: boolean[]; +}; + +function delimiterSpacedFromPlainSplit(input: string): boolean[] { + const spaced: boolean[] = []; + for (let i = 0; i < input.length; i++) { + if (input[i] !== ",") continue; + const next = input[i + 1]; + spaced.push(next !== undefined && HORIZONTAL_WS.test(next)); + } + return spaced; +} + +function scanQuotedCommaList(input: string): CommaListScan { const fields: string[] = []; + const delimiterSpaced: boolean[] = []; let buf = ""; let inQuotes = false; + const fallback = (): CommaListScan => ({ + fields: input.split(","), + delimiterSpaced: delimiterSpacedFromPlainSplit(input), + }); + for (let i = 0; i < input.length; i++) { const ch = input[i]; @@ -646,7 +652,7 @@ export function splitQuotedCommaList(input: string): string[] { continue; } // Quote closed by other text -> not real quoting; keep legacy. - return input.split(","); + return fallback(); } buf += ch; // commas (and everything else) are literal inside quotes continue; @@ -654,6 +660,8 @@ export function splitQuotedCommaList(input: string): string[] { if (ch === ",") { fields.push(buf); + const next = input[i + 1]; + delimiterSpaced.push(next !== undefined && HORIZONTAL_WS.test(next)); buf = ""; continue; } @@ -666,9 +674,42 @@ export function splitQuotedCommaList(input: string): string[] { buf += ch; } - if (inQuotes) return input.split(","); // unterminated quote -> legacy + if (inQuotes) return fallback(); // unterminated quote -> legacy fields.push(buf); - return fields; + return { fields, delimiterSpaced }; +} + +/** + * Split a comma-separated VALUE option list while honoring double-quoted fields, + * so a comma inside `"..."` stays literal (#239). CSV-style rules: + * - A field is quoted only when it STARTS with a double-quote (after optional + * leading whitespace); a quote anywhere else is literal. + * - Inside a quoted field, `""` is one literal quote and a comma is literal. + * - A closing quote is only honored when the next non-space char is a comma or + * end-of-input (STRICT close). + * + * Any input that is not cleanly quote-balanced — an unterminated quote, or a + * quote "closed" by other text (e.g. `"a"b`) — falls back to a plain comma + * split. That guarantees every token WITHOUT a balanced double-quoted field + * parses byte-identically to the pre-#239 behavior. + * + * Returns raw fields with the surrounding quotes stripped; callers apply the + * usual `.map(trim).filter(Boolean)`. Whitespace inside quotes is therefore not + * preserved — quoting protects commas, which survive the trim. + */ +export function splitQuotedCommaList(input: string): string[] { + return scanQuotedCommaList(input).fields; +} + +function inferInlineSeparator(rawOptionList: string): InlineSeparator { + const { delimiterSpaced } = scanQuotedCommaList(rawOptionList); + if ( + delimiterSpaced.length > 0 && + delimiterSpaced.every((spaced) => spaced) + ) { + return ", "; + } + return ","; } /** @@ -830,6 +871,14 @@ export function parseValueToken( multiFormat = "auto"; } + const resolvedMultiFormat: ResolvedMultiValueFormat = + multiFormat === "inline" + ? resolveMultiValueFormat( + "inline", + inferInlineSeparator(variablePart), + ) + : resolveMultiValueFormat(multiFormat); + // A bare `|custom` only enables free-text-with-autocomplete on an option-list // token (2+ values). On a single value it falls through to being parsed as the // literal default text "custom", silently pre-filling the prompt with that @@ -869,7 +918,7 @@ export function parseValueToken( trim, multiSelect, multiEmit, - multiFormat, + multiFormat: resolvedMultiFormat, }; } From 031d2f11b3a6ea04fbe94a5bb1f1a2650f51f0f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 13:50:53 +0000 Subject: [PATCH 2/5] test(format): lock trailing empty options out of inline spacing A trailing comma in a spaced VALUE list currently forces compact format:inline output. The next commit should make this pass. Co-authored-by: Christian Bager Bach Houmann --- src/formatters/completeFormatter.test.ts | 16 ++++++++++++++++ src/utils/valueSyntax.test.ts | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/src/formatters/completeFormatter.test.ts b/src/formatters/completeFormatter.test.ts index 598eb515..c0c255b8 100644 --- a/src/formatters/completeFormatter.test.ts +++ b/src/formatters/completeFormatter.test.ts @@ -1841,6 +1841,22 @@ describe("CompleteFormatter - remote prompt provider routing", () => { ).resolves.toBe("a, b"); }); + it("ignores a trailing empty option when inferring inline spacing", async () => { + const suggesterMulti = vi.fn( + async (_display: string[], _actual: string[]) => [ + "Empty A", + "Empty B", + ], + ); + const f = providerFormatter({ suggesterMulti }); + + await expect( + f.formatFileContent( + "{{VALUE:Empty A, Empty B,|multi|format:inline}}", + ), + ).resolves.toBe("Empty A, Empty B"); + }); + it("routes anonymous {{VALUE|type:checkbox}} to the provider's suggester, not the Obsidian modal", async () => { mocks.genericSuggesterSuggest.mockResolvedValue("false"); // modal answer (should be unused) const suggester = vi.fn(async () => "true"); diff --git a/src/utils/valueSyntax.test.ts b/src/utils/valueSyntax.test.ts index 40d16952..31feb155 100644 --- a/src/utils/valueSyntax.test.ts +++ b/src/utils/valueSyntax.test.ts @@ -290,6 +290,10 @@ describe("parseValueToken", () => { ['"a, b", c|multi|format:inline', ", "], ["a ,b|multi|format:inline", ","], ["a,b|multi|format:inline|text:option a, option b", ","], + ["Empty A, Empty B,|multi|format:inline", ", "], + ["a, b,|multi|format:inline", ", "], + ["a,,b|multi|format:inline", ","], + [",a, b|multi|format:inline", ", "], ] as const)( "infers the inline separator from %s", (token, separator) => { From 6a4d547a8231dc684fad564586a6bf938d5a0bec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 13:51:48 +0000 Subject: [PATCH 3/5] fix(format): ignore discarded empty options in inline spacing inferInlineSeparator counted every delimiter, including a trailing comma after the last real option. That compact flag broke unanimity on spaced lists such as Empty A, Empty B, Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/FormatSyntax.md | 5 +++-- src/utils/valueSyntax.ts | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index fd35172e..51485318 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -402,8 +402,9 @@ from where the placeholder appears: on its own line: `{{VALUE:Alpha,Beta|multi|format:markdown}}` becomes `- Alpha` followed by `- Beta`. - `|format:inline` writes comma-separated text. VALUE mimics the option-list - comma spacing. Space after every comma, or none. Mixed lists stay compact. - FIELD and FILE have no option list and stay `Alpha,Beta`. + comma spacing. Space after every comma between real options, or none. + Mixed lists stay compact. A trailing comma does not count. FIELD and FILE + have no option list and stay `Alpha,Beta`. - `|format:auto` is the default and preserves the context-sensitive behavior described below. diff --git a/src/utils/valueSyntax.ts b/src/utils/valueSyntax.ts index 73676a34..3c94630e 100644 --- a/src/utils/valueSyntax.ts +++ b/src/utils/valueSyntax.ts @@ -701,11 +701,21 @@ export function splitQuotedCommaList(input: string): string[] { return scanQuotedCommaList(input).fields; } +function fieldSurvives(field: string | undefined): boolean { + return Boolean(field?.trim()); +} + function inferInlineSeparator(rawOptionList: string): InlineSeparator { - const { delimiterSpaced } = scanQuotedCommaList(rawOptionList); + const { fields, delimiterSpaced } = scanQuotedCommaList(rawOptionList); + const betweenSurvivors: boolean[] = []; + for (let i = 0; i < delimiterSpaced.length; i++) { + if (fieldSurvives(fields[i]) && fieldSurvives(fields[i + 1])) { + betweenSurvivors.push(delimiterSpaced[i]); + } + } if ( - delimiterSpaced.length > 0 && - delimiterSpaced.every((spaced) => spaced) + betweenSurvivors.length > 0 && + betweenSurvivors.every((spaced) => spaced) ) { return ", "; } From 0e400c37c4fd4be2b9dbc94e2b7e011cf77dea02 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 14:21:36 +0000 Subject: [PATCH 4/5] feat(format): add format:spaced for comma-space multi-select Keep |format:inline compact (Alpha,Beta). |format:spaced always joins with ", ". Option-list whitespace stays a readability choice, not an output contract. VALUE, FIELD, and FILE share the named format. Fixes #1701 Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/FormatSyntax.md | 13 ++- src/engine/CaptureChoiceEngine.ts | 4 +- src/formatters/completeFormatter.test.ts | 15 +-- .../formatter-field-title-regression.test.ts | 14 +++ src/formatters/formatter.ts | 9 +- src/utils/FieldSuggestionParser.test.ts | 10 +- src/utils/FieldSuggestionParser.ts | 7 +- src/utils/fileSyntax.test.ts | 12 +- src/utils/fileSyntax.ts | 10 +- src/utils/multiValueFormat.test.ts | 20 ++-- src/utils/multiValueFormat.ts | 41 ++----- src/utils/valueSyntax.test.ts | 39 +------ src/utils/valueSyntax.ts | 107 ++++-------------- 13 files changed, 106 insertions(+), 195 deletions(-) diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index 51485318..d5e5ea2f 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -401,10 +401,10 @@ from where the placeholder appears: - `|format:markdown` writes a vertical Markdown bullet list. Put the placeholder on its own line: `{{VALUE:Alpha,Beta|multi|format:markdown}}` becomes `- Alpha` followed by `- Beta`. -- `|format:inline` writes comma-separated text. VALUE mimics the option-list - comma spacing. Space after every comma between real options, or none. - Mixed lists stay compact. A trailing comma does not count. FIELD and FILE - have no option list and stay `Alpha,Beta`. +- `|format:inline` always writes the compact comma-separated text form: + `Alpha,Beta`. +- `|format:spaced` writes the same text with a space after each comma: + `Alpha, Beta`. - `|format:auto` is the default and preserves the context-sensitive behavior described below. @@ -754,7 +754,8 @@ defaults as single-value FIELD prompts: `{{FIELD:topic|multi|folder:Projects|tag:active|default:Inbox}}`. FIELD multi-selects support the same explicit output formats as VALUE: -`|format:yaml`, `|format:markdown`, `|format:inline`, and `|format:auto`. +`|format:yaml`, `|format:markdown`, `|format:inline`, `|format:spaced`, +and `|format:auto`. For example, `topics: {{FIELD:topic|multi|format:yaml}}` always writes a native YAML list, including in template-backed captures. @@ -892,7 +893,7 @@ Good to know: - In a one-page input form, single and multi FILE pickers appear inline. Search matches the friendly title, file name, and full path. Selected files remain exact path-backed values internally, so commas in file names or labels are safe. FILE multi-selects support `|format:yaml`, `|format:markdown`, -`|format:inline`, and `|format:auto`. The format composes with `|link` and +`|format:inline`, `|format:spaced`, and `|format:auto`. The format composes with `|link` and `|path`, so `{{FILE:People|multi|link|format:yaml}}` writes a native YAML list of links without relying on the capture context. diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 1e070f59..70566ebb 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -113,7 +113,7 @@ function isCaptureContentEmpty(content: string): boolean { const MULTI_SELECT_TOKEN_REGEX = /\{\{(?:VALUE|FILE|FIELD):[^}]*\|\s*multi\s*(?=[:}|]|$)[^}]*\}\}/gi; const EXPLICIT_MULTI_FORMAT_REGEX = - /\|\s*format\s*:\s*(?:inline|yaml|markdown)\s*(?=\||}})/i; + /\|\s*format\s*:\s*(?:inline|spaced|yaml|markdown)\s*(?=\||}})/i; function hasContextualMultiSelectToken(input: string): boolean { return Array.from(input.matchAll(MULTI_SELECT_TOKEN_REGEX)).some( @@ -509,7 +509,7 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { hasContextualMultiSelectToken(this.choice?.format?.format ?? "") ) { log.logWarning( - "QuickAdd: {{VALUE:…|multi}}, {{FILE:…|multi}} and {{FIELD:…|multi}} in this capture write comma-separated strings by default. Add |format:yaml, |format:markdown or |format:inline to choose the output explicitly.", + "QuickAdd: {{VALUE:…|multi}}, {{FILE:…|multi}} and {{FIELD:…|multi}} in this capture write comma-separated strings by default. Add |format:yaml, |format:markdown, |format:inline or |format:spaced to choose the output explicitly.", ); } diff --git a/src/formatters/completeFormatter.test.ts b/src/formatters/completeFormatter.test.ts index c0c255b8..9d2b457b 100644 --- a/src/formatters/completeFormatter.test.ts +++ b/src/formatters/completeFormatter.test.ts @@ -1819,6 +1819,7 @@ describe("CompleteFormatter - remote prompt provider routing", () => { ["yaml", '["a", "c"]'], ["markdown", "- a\n- c"], ["inline", "a,c"], + ["spaced", "a, c"], ] as const)("renders VALUE multi-selects with |format:%s", async (format, expected) => { const suggesterMulti = vi.fn( async (_display: string[], _actual: string[]) => ["a", "c"], @@ -1830,7 +1831,7 @@ describe("CompleteFormatter - remote prompt provider routing", () => { ).resolves.toBe(expected); }); - it("renders VALUE inline with a space after each option-list comma", async () => { + it("keeps format:inline compact when the option list has spaces", async () => { const suggesterMulti = vi.fn( async (_display: string[], _actual: string[]) => ["a", "b"], ); @@ -1838,23 +1839,23 @@ describe("CompleteFormatter - remote prompt provider routing", () => { await expect( f.formatFileContent("{{VALUE:a, b|multi|format:inline}}"), - ).resolves.toBe("a, b"); + ).resolves.toBe("a,b"); }); - it("ignores a trailing empty option when inferring inline spacing", async () => { + it("joins format:spaced with a comma and a space, including spaced option names", async () => { const suggesterMulti = vi.fn( async (_display: string[], _actual: string[]) => [ - "Empty A", - "Empty B", + "option a", + "option b", ], ); const f = providerFormatter({ suggesterMulti }); await expect( f.formatFileContent( - "{{VALUE:Empty A, Empty B,|multi|format:inline}}", + "{{VALUE:option a, option b|multi|format:spaced}}", ), - ).resolves.toBe("Empty A, Empty B"); + ).resolves.toBe("option a, option b"); }); it("routes anonymous {{VALUE|type:checkbox}} to the provider's suggester, not the Obsidian modal", async () => { diff --git a/src/formatters/formatter-field-title-regression.test.ts b/src/formatters/formatter-field-title-regression.test.ts index 477cb24a..9971df06 100644 --- a/src/formatters/formatter-field-title-regression.test.ts +++ b/src/formatters/formatter-field-title-regression.test.ts @@ -231,6 +231,20 @@ describe("Formatter FIELD and TITLE namespace handling", () => { expect(formatter.getAndClearTemplatePropertyVars().size).toBe(0); }); + it("writes format:spaced as comma-space text inside a collection scope", async () => { + formatter.setMockFieldResponse("topic|multi|format:spaced", [ + "Alpha", + "Beta", + ]); + + await expect( + formatter.runFormatWithPropertyCollection( + "---\ntopics: {{FIELD:topic|multi|format:spaced}}\n---\n", + ), + ).resolves.toBe("---\ntopics: Alpha, Beta\n---\n"); + expect(formatter.getAndClearTemplatePropertyVars().size).toBe(0); + }); + it("collects FIELD multi arrays from YAML list item token positions", async () => { formatter.setMockFieldResponse("topic|multi", ["Alpha", "Beta"]); diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 4a0b8d02..20b8d5e9 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -55,8 +55,7 @@ import { } from "../utils/yamlScalarQuoting"; import { renderExplicitMultiValue, - resolveMultiValueFormat, - type ResolvedMultiValueFormat, + type MultiValueFormat, } from "../utils/multiValueFormat"; // |type: overrides whose value is a typed YAML scalar (Number, Boolean): an @@ -1069,7 +1068,7 @@ export abstract class Formatter { rawValue: unknown; fallbackKey: string; heuristicEnabled: boolean; - multiFormat?: ResolvedMultiValueFormat; + multiFormat?: MultiValueFormat; }): string | undefined { if (Array.isArray(args.rawValue) && args.multiFormat) { const explicit = renderExplicitMultiValue({ @@ -1495,9 +1494,7 @@ export abstract class Formatter { rawValue, fallbackKey: parsed.fieldName, heuristicEnabled: false, - multiFormat: - parsed.multiFormat ?? - resolveMultiValueFormat("auto"), + multiFormat: parsed.multiFormat ?? "auto", }) ?? rawValue.join(","); } else { replacement = escapeValueInsideQuotedYamlScalar( diff --git a/src/utils/FieldSuggestionParser.test.ts b/src/utils/FieldSuggestionParser.test.ts index 05d6f88b..1fe4a985 100644 --- a/src/utils/FieldSuggestionParser.test.ts +++ b/src/utils/FieldSuggestionParser.test.ts @@ -118,7 +118,15 @@ describe("FieldSuggestionParser", () => { "topics|multi|format:markdown", ); expect(result.multiSelect).toBe(true); - expect(result.multiFormat).toEqual({ format: "markdown" }); + expect(result.multiFormat).toBe("markdown"); + }); + + it("parses |format:spaced", () => { + const result = FieldSuggestionParser.parse( + "topics|multi|format:spaced", + ); + expect(result.multiSelect).toBe(true); + expect(result.multiFormat).toBe("spaced"); }); it("warns on |format: without |multi, even |format:auto", () => { diff --git a/src/utils/FieldSuggestionParser.ts b/src/utils/FieldSuggestionParser.ts index 8de60880..c2b589c2 100644 --- a/src/utils/FieldSuggestionParser.ts +++ b/src/utils/FieldSuggestionParser.ts @@ -7,9 +7,7 @@ import { import { suggestSimilarKeys } from "./suggestSimilarKeys"; import { parseMultiValueFormat, - resolveMultiValueFormat, type MultiValueFormat, - type ResolvedMultiValueFormat, } from "./multiValueFormat"; /** @@ -169,7 +167,7 @@ export class FieldSuggestionParser { fieldName: string; filters: FieldFilter; multiSelect?: boolean; - multiFormat?: ResolvedMultiValueFormat; + multiFormat?: MultiValueFormat; } { const parts = splitPipeParts(input).map((p) => p.trim()); const fieldName = parts[0]; @@ -324,12 +322,11 @@ export class FieldSuggestionParser { multiFormat = "auto"; } - const resolved = resolveMultiValueFormat(multiFormat); return { fieldName, filters, ...(multiSelect ? { multiSelect } : {}), - ...(resolved.format !== "auto" ? { multiFormat: resolved } : {}), + ...(multiFormat !== "auto" ? { multiFormat } : {}), }; } } diff --git a/src/utils/fileSyntax.test.ts b/src/utils/fileSyntax.test.ts index d2956c8e..76ba8071 100644 --- a/src/utils/fileSyntax.test.ts +++ b/src/utils/fileSyntax.test.ts @@ -42,14 +42,20 @@ describe("parseFileToken", () => { it("parses multi-select as FILE behavior", () => { const parsed = parseFileToken("People|multi"); expect(parsed?.multiSelect).toBe(true); - expect(parsed?.multiFormat).toEqual({ format: "auto" }); + expect(parsed?.multiFormat).toBe("auto"); expect(parsed?.variableKey).toContain("|multi"); }); it("parses an explicit multi-select format", () => { const parsed = parseFileToken("People|multi|format:yaml"); expect(parsed?.multiSelect).toBe(true); - expect(parsed?.multiFormat).toEqual({ format: "yaml" }); + expect(parsed?.multiFormat).toBe("yaml"); + }); + + it("parses |format:spaced", () => { + const parsed = parseFileToken("People|multi|format:spaced"); + expect(parsed?.multiSelect).toBe(true); + expect(parsed?.multiFormat).toBe("spaced"); }); it("warns on |format: without |multi, even |format:auto", () => { @@ -57,7 +63,7 @@ describe("parseFileToken", () => { const parsed = parseFileToken("People|format:auto", { warn: (msg) => warnings.push(msg), }); - expect(parsed?.multiFormat).toEqual({ format: "auto" }); + expect(parsed?.multiFormat).toBe("auto"); expect(warnings.some((m) => m.includes("needs |multi"))).toBe(true); }); diff --git a/src/utils/fileSyntax.ts b/src/utils/fileSyntax.ts index 87a8b96e..b3174bf2 100644 --- a/src/utils/fileSyntax.ts +++ b/src/utils/fileSyntax.ts @@ -8,10 +8,7 @@ import { parsePipeKeyValue, splitPipeParts, } from "./pipeSyntax"; -import { - resolveMultiValueFormat, - type ResolvedMultiValueFormat, -} from "./multiValueFormat"; +import type { MultiValueFormat } from "./multiValueFormat"; import type { WarnSink } from "./warnSink"; // Namespaces FILE variable values in the variables map, separate from plain @@ -44,7 +41,7 @@ export type ParsedFileToken = { /** Pick several files and store/render them as a list. */ multiSelect: boolean; /** Explicit output shape for a multi-select; auto preserves legacy behavior. */ - multiFormat: ResolvedMultiValueFormat; + multiFormat: MultiValueFormat; /** Variables-map key. Full token identity by default; `|name:` shares it. */ variableKey: string; }; @@ -187,8 +184,7 @@ export function parseFileToken( warn: options?.warn, }); const multiSelect = bareMultiSelect || (fieldParsed.multiSelect ?? false); - const multiFormat = - fieldParsed.multiFormat ?? resolveMultiValueFormat("auto"); + const multiFormat = fieldParsed.multiFormat ?? "auto"; const filter: FieldFilter = { folder: folderPath, tags: fieldParsed.filters.tags, diff --git a/src/utils/multiValueFormat.test.ts b/src/utils/multiValueFormat.test.ts index 26dd2967..5bf44ab8 100644 --- a/src/utils/multiValueFormat.test.ts +++ b/src/utils/multiValueFormat.test.ts @@ -5,7 +5,7 @@ import { } from "./multiValueFormat"; describe("multi-select output formatting", () => { - it.each(["auto", "inline", "yaml", "markdown"] as const)( + it.each(["auto", "inline", "spaced", "yaml", "markdown"] as const)( "parses %s", (format) => { expect(parseMultiValueFormat(format, "token")).toBe(format); @@ -16,7 +16,7 @@ describe("multi-select output formatting", () => { const warn = vi.fn(); expect(parseMultiValueFormat("table", "token", warn)).toBeUndefined(); expect(warn).toHaveBeenCalledWith( - expect.stringContaining("auto, inline, yaml, markdown"), + expect.stringContaining("auto, inline, spaced, yaml, markdown"), ); }); @@ -26,29 +26,29 @@ describe("multi-select output formatting", () => { input: "{{VALUE:a,b|multi}}", matchStart: 0, values: ["a", "b"], - format: { format: "auto" }, + format: "auto", }), ).toBeUndefined(); }); - it("renders compact inline output", () => { + it("renders inline output with the compact comma separator", () => { expect( renderExplicitMultiValue({ input: "value", matchStart: 0, values: ["Alpha", "Beta"], - format: { format: "inline", separator: "," }, + format: "inline", }), ).toBe("Alpha,Beta"); }); - it("renders spaced inline output", () => { + it("renders spaced output with a comma and a space", () => { expect( renderExplicitMultiValue({ input: "value", matchStart: 0, values: ["Alpha", "Beta"], - format: { format: "inline", separator: ", " }, + format: "spaced", }), ).toBe("Alpha, Beta"); }); @@ -59,7 +59,7 @@ describe("multi-select output formatting", () => { input: "topics: token", matchStart: 8, values: ["0042", "a: b", 'quoted "value"'], - format: { format: "yaml" }, + format: "yaml", }), ).toBe('["0042", "a: b", "quoted \\"value\\""]'); }); @@ -70,7 +70,7 @@ describe("multi-select output formatting", () => { input: "topics: token", matchStart: 8, values: [], - format: { format: "yaml" }, + format: "yaml", }), ).toBe("[]"); }); @@ -81,7 +81,7 @@ describe("multi-select output formatting", () => { input: " token", matchStart: 2, values: ["Alpha", "Beta\ncontinued"], - format: { format: "markdown" }, + format: "markdown", }), ).toBe("- Alpha\n - Beta\n continued"); }); diff --git a/src/utils/multiValueFormat.ts b/src/utils/multiValueFormat.ts index d9a06e1d..20483a8f 100644 --- a/src/utils/multiValueFormat.ts +++ b/src/utils/multiValueFormat.ts @@ -1,38 +1,16 @@ import { quoteYamlDouble } from "./yamlScalarQuoting"; import type { WarnSink } from "./warnSink"; -export type MultiValueFormat = "auto" | "inline" | "yaml" | "markdown"; - -export type InlineSeparator = "," | ", "; - -export type ResolvedMultiValueFormat = - | { format: "auto" | "yaml" | "markdown" } - | { format: "inline"; separator: InlineSeparator }; +export type MultiValueFormat = "auto" | "inline" | "spaced" | "yaml" | "markdown"; const MULTI_VALUE_FORMATS = new Set([ "auto", "inline", + "spaced", "yaml", "markdown", ]); -export function resolveMultiValueFormat( - format: "inline", - separator: InlineSeparator, -): ResolvedMultiValueFormat; -export function resolveMultiValueFormat( - format: MultiValueFormat, -): ResolvedMultiValueFormat; -export function resolveMultiValueFormat( - format: MultiValueFormat, - separator?: InlineSeparator, -): ResolvedMultiValueFormat { - if (format === "inline") { - return { format: "inline", separator: separator ?? "," }; - } - return { format }; -} - export function parseMultiValueFormat( raw: string, tokenDisplay: string, @@ -44,7 +22,7 @@ export function parseMultiValueFormat( } warn?.( - `QuickAdd: Unsupported multi-select format "${raw}" in "${tokenDisplay}". Supported formats: auto, inline, yaml, markdown.`, + `QuickAdd: Unsupported multi-select format "${raw}" in "${tokenDisplay}". Supported formats: auto, inline, spaced, yaml, markdown.`, ); return undefined; } @@ -66,18 +44,21 @@ export function renderExplicitMultiValue(args: { input: string; matchStart: number; values: readonly unknown[]; - format: ResolvedMultiValueFormat; + format: MultiValueFormat; }): string | undefined { const { input, matchStart, values, format } = args; - switch (format.format) { + const strings = values.map((value) => String(value)); + + switch (format) { case "auto": return undefined; case "inline": - return values.map((value) => String(value)).join(format.separator); + return strings.join(","); + case "spaced": + return strings.join(", "); case "yaml": - return `[${values.map((value) => quoteYamlDouble(String(value))).join(", ")}]`; + return `[${strings.map(quoteYamlDouble).join(", ")}]`; case "markdown": { - const strings = values.map((value) => String(value)); if (strings.length === 0) return ""; const indent = currentLineIndent(input, matchStart); return strings.map(renderMarkdownItem).join(`\n${indent}`); diff --git a/src/utils/valueSyntax.test.ts b/src/utils/valueSyntax.test.ts index 31feb155..70c37965 100644 --- a/src/utils/valueSyntax.test.ts +++ b/src/utils/valueSyntax.test.ts @@ -249,7 +249,7 @@ describe("parseValueToken", () => { expect(parsed?.multiEmit).toBe("linklist"); }); - it.each(["yaml", "markdown", "inline"] as const)( + it.each(["yaml", "markdown", "inline", "spaced"] as const)( "parses |format:%s separately from |multi item emission", (format) => { const parsed = parseValueToken( @@ -257,53 +257,22 @@ describe("parseValueToken", () => { ); expect(parsed?.multiSelect).toBe(true); expect(parsed?.multiEmit).toBe("linklist"); - expect(parsed?.multiFormat).toEqual( - format === "inline" - ? { format: "inline", separator: "," } - : { format }, - ); + expect(parsed?.multiFormat).toBe(format); }, ); it("warns and ignores an explicit format without |multi", () => { const warnSpy = vi.spyOn(log, "logWarning").mockImplementation(() => {}); - expect(parseValueToken("Only|format:yaml")?.multiFormat).toEqual({ - format: "auto", - }); + expect(parseValueToken("Only|format:yaml")?.multiFormat).toBe("auto"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("needs |multi")); }); it("warns on an explicit |format:auto without |multi (a silent no-op otherwise)", () => { const warnSpy = vi.spyOn(log, "logWarning").mockImplementation(() => {}); - expect(parseValueToken("Only|format:auto")?.multiFormat).toEqual({ - format: "auto", - }); + expect(parseValueToken("Only|format:auto")?.multiFormat).toBe("auto"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("needs |multi")); }); - it.each([ - ["option a,option b|multi|format:inline", ","], - ["option a, option b|multi|format:inline", ", "], - ["a, b,c|multi|format:inline", ","], - ["a,b, c|multi|format:inline", ","], - ['"a, b",c|multi|format:inline', ","], - ['"a, b", c|multi|format:inline', ", "], - ["a ,b|multi|format:inline", ","], - ["a,b|multi|format:inline|text:option a, option b", ","], - ["Empty A, Empty B,|multi|format:inline", ", "], - ["a, b,|multi|format:inline", ", "], - ["a,,b|multi|format:inline", ","], - [",a, b|multi|format:inline", ", "], - ] as const)( - "infers the inline separator from %s", - (token, separator) => { - expect(parseValueToken(token)?.multiFormat).toEqual({ - format: "inline", - separator, - }); - }, - ); - it("warns and ignores |multi without an option list", () => { const warnSpy = vi.spyOn(log, "logWarning").mockImplementation(() => {}); expect(parseValueToken("Only|multi")?.multiSelect).toBe(false); diff --git a/src/utils/valueSyntax.ts b/src/utils/valueSyntax.ts index 3c94630e..03fdc508 100644 --- a/src/utils/valueSyntax.ts +++ b/src/utils/valueSyntax.ts @@ -8,10 +8,7 @@ import { import { isSupportedCaseStyle, SUPPORTED_CASE_STYLES } from "./caseTransform"; import { parseMultiValueFormat, - resolveMultiValueFormat, - type InlineSeparator, type MultiValueFormat, - type ResolvedMultiValueFormat, } from "./multiValueFormat"; import { NOTICE_WARN, SILENT_WARN, type WarnSink } from "./warnSink"; @@ -96,7 +93,7 @@ export type ParsedValueToken = { /** |multi:linklist wraps each pick as [[name]]; defaults to plain text. */ multiEmit: MultiEmit; /** Explicit output shape for a multi-select; auto preserves legacy behavior. */ - multiFormat: ResolvedMultiValueFormat; + multiFormat: MultiValueFormat; }; export function buildValueVariableKey( @@ -606,32 +603,29 @@ function isDoubleQuote(ch: string | undefined): boolean { // excludes them), but excluding them here keeps the helper safe if reused. const HORIZONTAL_WS = /[^\S\r\n]/; -type CommaListScan = { - fields: string[]; - delimiterSpaced: boolean[]; -}; - -function delimiterSpacedFromPlainSplit(input: string): boolean[] { - const spaced: boolean[] = []; - for (let i = 0; i < input.length; i++) { - if (input[i] !== ",") continue; - const next = input[i + 1]; - spaced.push(next !== undefined && HORIZONTAL_WS.test(next)); - } - return spaced; -} - -function scanQuotedCommaList(input: string): CommaListScan { +/** + * Split a comma-separated VALUE option list while honoring double-quoted fields, + * so a comma inside `"..."` stays literal (#239). CSV-style rules: + * - A field is quoted only when it STARTS with a double-quote (after optional + * leading whitespace); a quote anywhere else is literal. + * - Inside a quoted field, `""` is one literal quote and a comma is literal. + * - A closing quote is only honored when the next non-space char is a comma or + * end-of-input (STRICT close). + * + * Any input that is not cleanly quote-balanced — an unterminated quote, or a + * quote "closed" by other text (e.g. `"a"b`) — falls back to a plain comma + * split. That guarantees every token WITHOUT a balanced double-quoted field + * parses byte-identically to the pre-#239 behavior. + * + * Returns raw fields with the surrounding quotes stripped; callers apply the + * usual `.map(trim).filter(Boolean)`. Whitespace inside quotes is therefore not + * preserved — quoting protects commas, which survive the trim. + */ +export function splitQuotedCommaList(input: string): string[] { const fields: string[] = []; - const delimiterSpaced: boolean[] = []; let buf = ""; let inQuotes = false; - const fallback = (): CommaListScan => ({ - fields: input.split(","), - delimiterSpaced: delimiterSpacedFromPlainSplit(input), - }); - for (let i = 0; i < input.length; i++) { const ch = input[i]; @@ -652,7 +646,7 @@ function scanQuotedCommaList(input: string): CommaListScan { continue; } // Quote closed by other text -> not real quoting; keep legacy. - return fallback(); + return input.split(","); } buf += ch; // commas (and everything else) are literal inside quotes continue; @@ -660,8 +654,6 @@ function scanQuotedCommaList(input: string): CommaListScan { if (ch === ",") { fields.push(buf); - const next = input[i + 1]; - delimiterSpaced.push(next !== undefined && HORIZONTAL_WS.test(next)); buf = ""; continue; } @@ -674,52 +666,9 @@ function scanQuotedCommaList(input: string): CommaListScan { buf += ch; } - if (inQuotes) return fallback(); // unterminated quote -> legacy + if (inQuotes) return input.split(","); // unterminated quote -> legacy fields.push(buf); - return { fields, delimiterSpaced }; -} - -/** - * Split a comma-separated VALUE option list while honoring double-quoted fields, - * so a comma inside `"..."` stays literal (#239). CSV-style rules: - * - A field is quoted only when it STARTS with a double-quote (after optional - * leading whitespace); a quote anywhere else is literal. - * - Inside a quoted field, `""` is one literal quote and a comma is literal. - * - A closing quote is only honored when the next non-space char is a comma or - * end-of-input (STRICT close). - * - * Any input that is not cleanly quote-balanced — an unterminated quote, or a - * quote "closed" by other text (e.g. `"a"b`) — falls back to a plain comma - * split. That guarantees every token WITHOUT a balanced double-quoted field - * parses byte-identically to the pre-#239 behavior. - * - * Returns raw fields with the surrounding quotes stripped; callers apply the - * usual `.map(trim).filter(Boolean)`. Whitespace inside quotes is therefore not - * preserved — quoting protects commas, which survive the trim. - */ -export function splitQuotedCommaList(input: string): string[] { - return scanQuotedCommaList(input).fields; -} - -function fieldSurvives(field: string | undefined): boolean { - return Boolean(field?.trim()); -} - -function inferInlineSeparator(rawOptionList: string): InlineSeparator { - const { fields, delimiterSpaced } = scanQuotedCommaList(rawOptionList); - const betweenSurvivors: boolean[] = []; - for (let i = 0; i < delimiterSpaced.length; i++) { - if (fieldSurvives(fields[i]) && fieldSurvives(fields[i + 1])) { - betweenSurvivors.push(delimiterSpaced[i]); - } - } - if ( - betweenSurvivors.length > 0 && - betweenSurvivors.every((spaced) => spaced) - ) { - return ", "; - } - return ","; + return fields; } /** @@ -881,14 +830,6 @@ export function parseValueToken( multiFormat = "auto"; } - const resolvedMultiFormat: ResolvedMultiValueFormat = - multiFormat === "inline" - ? resolveMultiValueFormat( - "inline", - inferInlineSeparator(variablePart), - ) - : resolveMultiValueFormat(multiFormat); - // A bare `|custom` only enables free-text-with-autocomplete on an option-list // token (2+ values). On a single value it falls through to being parsed as the // literal default text "custom", silently pre-filling the prompt with that @@ -928,7 +869,7 @@ export function parseValueToken( trim, multiSelect, multiEmit, - multiFormat: resolvedMultiFormat, + multiFormat, }; } From 5e3cdab5fd5f4af47b1f9bbcdcdb28dd1b53f2cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 15:02:48 +0000 Subject: [PATCH 5/5] docs(format): show format:spaced with the issue example Cloudflare Pages failed in 0s on the previous commit; the local Astro build of FormatSyntax succeeded. This also retriggers the preview. Co-authored-by: Christian Bager Bach Houmann --- docs/src/content/docs/docs/FormatSyntax.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index d5e5ea2f..7986888f 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -404,7 +404,7 @@ from where the placeholder appears: - `|format:inline` always writes the compact comma-separated text form: `Alpha,Beta`. - `|format:spaced` writes the same text with a space after each comma: - `Alpha, Beta`. + `{{VALUE:option a, option b|multi|format:spaced}}` becomes `option a, option b`. - `|format:auto` is the default and preserves the context-sensitive behavior described below.