patch 9.2.0223: Option handling for key:value suboptions is limited
Commit:
https://github.com/vim/vim/commit/e2f4e18437074868d89ed5c368af8c55ddc394e1
Author: Hirohito Higashi <[email protected]>
Date: Sun Mar 22 16:08:01 2026 +0000
patch 9.2.0223: Option handling for key:value suboptions is limited
Problem: Option handling for key:value suboptions is limited
Solution: Improve :set+=, :set-= and :set^= for options that use
"key:value" pairs (Hirohito Higashi)
For comma-separated options with P_COLON (e.g., diffopt, listchars,
fillchars), :set += -= ^= now processes each comma-separated item
individually instead of treating the whole value as a single string.
For :set += and :set ^=:
- A "key:value" item where the key already exists with a different value:
the old item is replaced.
- An exact duplicate item is left unchanged.
- A new item is appended (+=) or prepended (^=).
For :set -=:
- A "key:value" or "key:" item removes by key match regardless of value.
- A non-colon item removes by exact match.
This also handles multiple non-colon items (e.g., :set
diffopt-=filler,internal) by processing each item individually, making
the behavior order-independent.
Previously, :set += simply appended the value, causing duplicate keys to
accumulate.
fixes: #18495
closes: #19783
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Signed-off-by: Hirohito Higashi <[email protected]>
Signed-off-by: Christian Brabandt <[email protected]>
diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt
index bddfa9921..d28646778 100644
--- a/runtime/doc/options.txt
+++ b/runtime/doc/options.txt
@@ -1,4 +1,4 @@
-*options.txt* For Vim version 9.2. Last change: 2026 Mar 16
+*options.txt* For Vim version 9.2. Last change: 2026 Mar 22
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -97,6 +97,17 @@ achieve special effects. These options come in three forms:
If the option is a list of flags, superfluous flags
are removed. When adding a flag that was already
present the option value doesn't change.
+ When the option supports "key:value" items and {value}
+ contains a "key:value" item or multiple
+ comma-separated items, each item is processed
+ individually:
+ - A "key:value" item where the key already exists with
+ a different value: the old item is removed and the
+ new item is appended to the end.
+ - A "key:value" item that is an exact duplicate is
+ left unchanged.
+ - Other items that already exist are left unchanged.
+ - New items are appended to the end.
Also see |:set-args| above.
:se[t] {option}^={value} *:set^=*
@@ -104,6 +115,11 @@ achieve special effects. These options come in three
forms:
the {value} to a string option. When the option is a
comma-separated list, a comma is added, unless the
value was empty.
+ When the option supports "key:value" items and {value}
+ contains a "key:value" item or multiple
+ comma-separated items, each item is processed
+ individually. Works like |:set+=| but new items are
+ prepended to the beginning instead of appended.
Also see |:set-args| above.
:se[t] {option}-={value} *:set-=*
@@ -116,6 +132,12 @@ achieve special effects. These options come in three
forms:
When the option is a list of flags, {value} must be
exactly as they appear in the option. Remove flags
one by one to avoid problems.
+ When the option supports "key:value" items and {value}
+ contains a "key:value" item or multiple
+ comma-separated items, each item is processed
+ individually. A "key:value" item removes the existing
+ item with that key regardless of its value. A "key:"
+ item also removes by key match.
The individual values from a comma separated list or
list of flags can be inserted by typing 'wildchar'.
See |complete-set-option|.
diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt
index e9a949e37..316899485 100644
--- a/runtime/doc/version9.txt
+++ b/runtime/doc/version9.txt
@@ -1,4 +1,4 @@
-*version9.txt* For Vim version 9.2. Last change: 2026 Mar 19
+*version9.txt* For Vim version 9.2. Last change: 2026 Mar 22
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -52609,6 +52609,8 @@ Other ~
- Support for "dap" channel mode for the |debug-adapter-protocol|.
- |status-line| can use several lines, see 'statuslineopt'.
- New "leadtab" value for the 'listchars' setting.
+- Improved |:set+=|, |:set^=| and |:set-=| handling of comma-separated
"key:value"
+ pairs individually (e.g. 'listchars', 'fillchars', 'diffopt').
xxd ~
---
diff --git a/src/option.c b/src/option.c
index 60af09033..454465299 100644
--- a/src/option.c
+++ b/src/option.c
@@ -1887,6 +1887,247 @@ stropt_remove_val(
}
}
+/*
+ * Find a comma-separated item in "src" that matches the key part of "key".
+ * The key is the part before ':'. "keylen" is the length including ':'.
+ * Returns a pointer to the found item in "src", or NULL if not found.
+ * Sets "*itemlenp" to the length of the found item (up to ',' or NUL).
+ */
+ static char_u *
+find_key_item(char_u *src, char_u *key, int keylen, int *itemlenp)
+{
+ char_u *p = src;
+
+ while (*p != NUL)
+ {
+ // Check if this item starts with the same key
+ if ((p == src || *(p - 1) == ',')
+ && STRNCMP(p, key, keylen) == 0)
+ {
+ // Find the end of this item
+ char_u *end = vim_strchr(p, ',');
+ if (end == NULL)
+ end = p + STRLEN(p);
+ *itemlenp = (int)(end - p);
+ return p;
+ }
+ ++p;
+ }
+ return NULL;
+}
+
+/*
+ * Remove one item of length "itemlen" at position "item" from comma-separated
+ * string "str" in-place. Handles the comma before or after the item.
+ */
+ static void
+remove_comma_item(char_u *str, char_u *item, int itemlen)
+{
+ if (item[itemlen] == ',')
+ // Remove item and trailing comma
+ STRMOVE(item, item + itemlen + 1);
+ else if (item > str && *(item - 1) == ',')
+ // Last item: remove leading comma and item
+ STRMOVE(item - 1, item + itemlen);
+ else
+ // Only item
+ *item = NUL;
+}
+
+/*
+ * Remove all items matching "key" (with ':') from comma-separated string "str"
+ * in-place. If "skip" is not NULL, the item at that position is kept.
+ */
+ static void
+remove_key_item(char_u *str, char_u *key, int keylen, char_u *skip)
+{
+ int itemlen;
+ char_u *found;
+
+ while ((found = find_key_item(str, key, keylen, &itemlen)) != NULL)
+ {
+ if (found == skip)
+ {
+ // Search for the next match after this one.
+ char_u *next = found + itemlen;
+ if (*next == ',')
+ ++next;
+ found = find_key_item(next, key, keylen, &itemlen);
+ if (found == NULL)
+ break;
+ }
+
+ remove_comma_item(str, found, itemlen);
+ }
+}
+
+/*
+ * Append a comma-separated item to the end of "str" in-place.
+ * Adds a comma before the item if "str" is not empty.
+ */
+ static void
+append_item(char_u *str, char_u *item, int item_len)
+{
+ int len = (int)STRLEN(str);
+
+ if (len > 0)
+ str[len++] = ',';
+ mch_memmove(str + len, item, (size_t)item_len);
+ str[len + item_len] = NUL;
+}
+
+/*
+ * Prepend a comma-separated item to the beginning of "str" in-place.
+ * Adds a comma after the item if "str" is not empty.
+ */
+ static void
+prepend_item(char_u *str, char_u *item, int item_len)
+{
+ int len = (int)STRLEN(str);
+ int comma = (len > 0) ? 1 : 0;
+
+ mch_memmove(str + item_len + comma, str, (size_t)len + 1);
+ mch_memmove(str, item, (size_t)item_len);
+ if (comma)
+ str[item_len] = ',';
+}
+
+/*
+ * For a P_COMMA option: process "key:value" items in "newval" individually.
+ * Each comma-separated item in "newval" is checked against "origval":
+ *
+ * For OP_ADDING/OP_PREPENDING, each item is handled as follows:
+ * - colon item, key exists with different value: replace (remove old, add)
+ * - colon item, exact duplicate: do nothing
+ * - colon item, not found: add to end
+ * - non-colon item, exists: do nothing
+ * - non-colon item, not found: add to end
+ *
+ * For OP_REMOVING, each item is handled as follows:
+ * - colon item: remove by key match
+ * - non-colon item: remove by exact match
+ *
+ * The result is written to "newval".
+ * Returns true if the operation was fully handled (caller should skip the
+ * normal add/remove logic). Returns false if newval is a single non-colon
+ * item, meaning the caller should use the existing code path.
+ */
+ static bool
+stropt_handle_keymatch(
+ char_u *origval,
+ char_u *newval,
+ set_op_T op,
+ int flags UNUSED)
+{
+ char_u *p;
+ char_u *item_start;
+
+ // Check if newval contains any "key:value" item or multiple
+ // comma-separated items. If neither, let the caller use the existing
+ // code path.
+ if (vim_strchr(newval, ':') == NULL && vim_strchr(newval, ',') == NULL)
+ return false;
+
+ // Work on a copy of newval for iteration.
+ char_u *newval_copy = vim_strsave(newval);
+ if (newval_copy == NULL)
+ return false;
+
+ // Build the result in newval. Start with a copy of origval, then
+ // modify it per-item. newval buffer has room for origval + arg.
+ STRCPY(newval, origval);
+
+ // Process each item individually, modifying newval in-place.
+ item_start = newval_copy;
+ for (;;)
+ {
+ p = vim_strchr(item_start, ',');
+ int item_len = (p == NULL)
+ ? (int)STRLEN(item_start) : (int)(p - item_start);
+
+ if (item_len > 0)
+ {
+ char_u *colon = vim_strchr(item_start, ':');
+ if (colon != NULL && colon < item_start + item_len)
+ {
+ int keylen = (int)(colon - item_start) + 1;
+
+ if (op == OP_ADDING || op == OP_PREPENDING)
+ {
+ int old_itemlen;
+ char_u *found = find_key_item(newval, item_start,
+ keylen, &old_itemlen);
+ if (found != NULL)
+ {
+ if (old_itemlen == item_len
+ && STRNCMP(found, item_start,
+ item_len) == 0)
+ {
+ // Exact duplicate: keep it in place, but
+ // remove other items with the same key.
+ remove_key_item(newval, item_start,
+ keylen, found);
+ }
+ else
+ {
+ // Key match with different value: remove all
+ // items with the same key, then add.
+ remove_key_item(newval, item_start,
+ keylen, NULL);
+ if (op == OP_PREPENDING)
+ prepend_item(newval, item_start, item_len);
+ else
+ append_item(newval, item_start, item_len);
+ }
+ }
+ else
+ {
+ // New item.
+ if (op == OP_PREPENDING)
+ prepend_item(newval, item_start, item_len);
+ else
+ append_item(newval, item_start, item_len);
+ }
+ }
+ else if (op == OP_REMOVING)
+ remove_key_item(newval, item_start, keylen, NULL);
+ }
+ else
+ {
+ if (op == OP_ADDING || op == OP_PREPENDING)
+ {
+ char_u *found = find_dup_item(newval, item_start,
+ item_len, P_COMMA);
+ if (found == NULL)
+ {
+ // New item.
+ if (op == OP_PREPENDING)
+ prepend_item(newval, item_start, item_len);
+ else
+ append_item(newval, item_start, item_len);
+ }
+ // else: exact duplicate — do nothing
+ }
+ else if (op == OP_REMOVING)
+ {
+ char_u *found = find_dup_item(newval, item_start,
+ item_len, P_COMMA);
+ if (found != NULL)
+ remove_comma_item(newval, found, item_len);
+ }
+ }
+ }
+
+ if (p == NULL)
+ break;
+ item_start = p + 1;
+ }
+
+ vim_free(newval_copy);
+
+ return true;
+}
+
/*
* Remove flags that appear twice in the string option value 'newval'.
*/
@@ -2002,34 +2243,43 @@ stropt_get_newval(
goto done;
}
- // locate newval[] in origval[] when removing it and when adding to
- // avoid duplicates
- int len = 0;
- if (op == OP_REMOVING || (flags & P_NODUP))
+ // For P_COMMA|P_COLON options with "key:value" items: process
+ // each item individually by matching on the key part. If
+ // handled, skip the normal add/remove logic below.
+ if ((flags & P_COMMA) && (flags & P_COLON) && op != OP_NONE
+ && stropt_handle_keymatch(origval, newval, op, flags))
+ ; // fully handled
+ else
{
- len = (int)STRLEN(newval);
- s = find_dup_item(origval, newval, len, flags);
-
- // do not add if already there
- if ((op == OP_ADDING || op == OP_PREPENDING) && s != NULL)
+ // locate newval[] in origval[] when removing it and when
+ // adding to avoid duplicates
+ int len = 0;
+ if (op == OP_REMOVING || (flags & P_NODUP))
{
- op = OP_NONE;
- STRCPY(newval, origval);
+ len = (int)STRLEN(newval);
+ s = find_dup_item(origval, newval, len, flags);
+
+ // do not add if already there
+ if ((op == OP_ADDING || op == OP_PREPENDING) && s != NULL)
+ {
+ op = OP_NONE;
+ STRCPY(newval, origval);
+ }
+
+ // if no duplicate, move pointer to end of original value
+ if (s == NULL)
+ s = origval + (int)STRLEN(origval);
}
- // if no duplicate, move pointer to end of original value
- if (s == NULL)
- s = origval + (int)STRLEN(origval);
+ // concatenate the two strings; add a ',' if needed
+ if (op == OP_ADDING || op == OP_PREPENDING)
+ stropt_concat_with_comma(origval, newval, op, flags);
+ else if (op == OP_REMOVING)
+ // Remove newval[] from origval[]. (Note: "len" has been
+ // set above and is used here).
+ stropt_remove_val(origval, newval, flags, s, len);
}
- // concatenate the two strings; add a ',' if needed
- if (op == OP_ADDING || op == OP_PREPENDING)
- stropt_concat_with_comma(origval, newval, op, flags);
- else if (op == OP_REMOVING)
- // Remove newval[] from origval[]. (Note: "len" has been set above
- // and is used here).
- stropt_remove_val(origval, newval, flags, s, len);
-
if (flags & P_FLAGLIST)
// Remove flags that appear twice.
stropt_remove_dupflags(newval, flags);
diff --git a/src/optiondefs.h b/src/optiondefs.h
index 40733fdff..a40c4a77f 100644
--- a/src/optiondefs.h
+++ b/src/optiondefs.h
@@ -1011,7 +1011,7 @@ static struct vimoption options[] =
did_set_filetype_or_syntax, NULL,
{(char_u *)"", (char_u *)0L}
SCTX_INIT},
- {"fillchars", "fcs", P_STRING|P_VI_DEF|P_RALL|P_ONECOMMA|P_NODUP,
+ {"fillchars", "fcs",
P_STRING|P_VI_DEF|P_RALL|P_ONECOMMA|P_NODUP|P_COLON,
(char_u *)&p_fcs, PV_FCS, did_set_chars_option,
expand_set_chars_option,
{(char_u *)"vert:|,fold:-,eob:~,lastline:@",
(char_u *)0L}
@@ -1672,7 +1672,7 @@ static struct vimoption options[] =
{"list", NULL, P_BOOL|P_VI_DEF|P_RWIN,
(char_u *)VAR_WIN, PV_LIST, NULL, NULL,
{(char_u *)FALSE, (char_u *)0L} SCTX_INIT},
- {"listchars", "lcs", P_STRING|P_VI_DEF|P_RALL|P_ONECOMMA|P_NODUP,
+ {"listchars", "lcs",
P_STRING|P_VI_DEF|P_RALL|P_ONECOMMA|P_NODUP|P_COLON,
(char_u *)&p_lcs, PV_LCS, did_set_chars_option,
expand_set_chars_option,
{(char_u *)"eol:$", (char_u *)0L} SCTX_INIT},
{"loadplugins", "lpl", P_BOOL|P_VI_DEF,
diff --git a/src/testdir/test_listchars.vim b/src/testdir/test_listchars.vim
index fd0e146b2..741b20d9b 100644
--- a/src/testdir/test_listchars.vim
+++ b/src/testdir/test_listchars.vim
@@ -250,7 +250,7 @@ func Test_listchars()
\ '.-+*0++0>>>>$',
\ '$'
\ ]
- call assert_equal('eol:$,nbsp:S,leadmultispace:.-+*,space:+,trail:>,eol:$',
&listchars)
+ call assert_equal('eol:$,nbsp:S,leadmultispace:.-+*,space:+,trail:>',
&listchars)
call Check_listchars(expected, 7)
call Check_listchars(expected, 6, -1, 1)
call Check_listchars(expected, 6, -1, 2)
@@ -338,11 +338,21 @@ func Test_listchars()
\ 'XyYX0Xy0XyYX$',
\ '$'
\ ]
+ call assert_equal('eol:$,space:x,multispace:XyY', &listchars)
+ call Check_listchars(expected, 7)
+ call Check_listchars(expected, 6, -1, 6)
+ call assert_equal(expected, split(execute("%list"), "
"))
+
+ " when using :let, multiple 'multispace:' fields can exist
+ " and the last occurrence of 'multispace:' is used
+ let &listchars = 'eol:$,multispace:yYzZ,space:x,multispace:XyY'
call assert_equal('eol:$,multispace:yYzZ,space:x,multispace:XyY', &listchars)
call Check_listchars(expected, 7)
call Check_listchars(expected, 6, -1, 6)
call assert_equal(expected, split(execute("%list"), "
"))
+ " restore to single multispace: for subsequent tests
+ set listchars=eol:$,space:x,multispace:XyY
set listchars+=lead:>,trail:<
let expected = [
@@ -359,8 +369,7 @@ func Test_listchars()
call assert_equal(expected, split(execute("%list"), "
"))
" removing 'multispace:'
- set listchars-=multispace:XyY
- set listchars-=multispace:yYzZ
+ set listchars-=multispace:
let expected = [
\ '>>>>ffff<<<<$',
diff --git a/src/testdir/test_options.vim b/src/testdir/test_options.vim
index 8097a3f04..d33c06252 100644
--- a/src/testdir/test_options.vim
+++ b/src/testdir/test_options.vim
@@ -2951,4 +2951,145 @@ func Test_showcmd()
let &cp = _cp
endfunc
+" Test that :set+= and :set-= handle "key:value" items in comma-separated
+" options by matching on the key part.
+func Test_comma_option_key_value()
+ " += replaces existing item with same key
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt+=algorithm:histogram
+ call assert_equal('internal,filler,algorithm:histogram', &diffopt)
+
+ " += with exact duplicate does nothing
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt+=algorithm:patience
+ call assert_equal('internal,filler,algorithm:patience', &diffopt)
+
+ " += with multiple items, each processed individually
+ set diffopt=algorithm:patience,filler
+ set diffopt+=algorithm:histogram,filler
+ call assert_equal('filler,algorithm:histogram', &diffopt)
+
+ " += with non-colon item appends normally
+ set diffopt=internal,filler
+ set diffopt+=iwhite
+ call assert_equal('internal,filler,iwhite', &diffopt)
+
+ " += repeated updates
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt+=algorithm:histogram
+ set diffopt+=algorithm:minimal
+ set diffopt+=algorithm:myers
+ call assert_equal('internal,filler,algorithm:myers', &diffopt)
+
+ " += all exact duplicates does nothing
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt+=algorithm:patience,filler
+ call assert_equal('internal,filler,algorithm:patience', &diffopt)
+
+ " -= with "key:" removes item regardless of value
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt-=algorithm:
+ call assert_equal('internal,filler', &diffopt)
+
+ " -= with "key:value" also matches by key
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt-=algorithm:histogram
+ call assert_equal('internal,filler', &diffopt)
+
+ " -= without colon does not match "key:value" items
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt-=algorithm
+ call assert_equal('internal,filler,algorithm:patience', &diffopt)
+
+ " -= with multiple non-colon items (order independent)
+ set diffopt=internal,filler,closeoff
+ set diffopt-=filler,internal
+ call assert_equal('closeoff', &diffopt)
+
+ " -= with multiple non-colon items (same order as in option)
+ set diffopt=internal,filler,closeoff
+ set diffopt-=internal,filler
+ call assert_equal('closeoff', &diffopt)
+
+ " -= with multiple items: non-colon and colon mixed
+ set diffopt&
+ set diffopt-=indent-heuristic,inline:char
+ call assert_equal('internal,filler,closeoff', &diffopt)
+
+ " -= with multiple items: colon and non-colon mixed (reverse order)
+ set diffopt&
+ set diffopt-=inline:char,indent-heuristic
+ call assert_equal('internal,filler,closeoff', &diffopt)
+
+ " += with multiple non-colon items
+ set diffopt=internal,filler
+ set diffopt+=closeoff,iwhite
+ call assert_equal('internal,filler,closeoff,iwhite', &diffopt)
+
+ " += with multiple non-colon items, some already exist
+ set diffopt=internal,filler,closeoff
+ set diffopt+=filler,iwhite
+ call assert_equal('internal,filler,closeoff,iwhite', &diffopt)
+
+ " -= with multiple items including key match
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt-=algorithm:,filler
+ call assert_equal('internal', &diffopt)
+
+ " -= key match when item is at the beginning
+ set diffopt=algorithm:patience,internal,filler
+ set diffopt-=algorithm:
+ call assert_equal('internal,filler', &diffopt)
+
+ " -= key match when item is at the end
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt-=algorithm:
+ call assert_equal('internal,filler', &diffopt)
+
+ " -= key match when item is the only item
+ set diffopt=algorithm:patience
+ set diffopt-=algorithm:
+ call assert_equal('', &diffopt)
+
+ " ^= prepends new item
+ set diffopt=internal,filler
+ set diffopt^=algorithm:histogram
+ call assert_equal('algorithm:histogram,internal,filler', &diffopt)
+
+ " ^= replaces item and prepends
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt^=algorithm:histogram
+ call assert_equal('algorithm:histogram,internal,filler', &diffopt)
+
+ " ^= with exact duplicate does nothing
+ set diffopt=internal,filler,algorithm:patience
+ set diffopt^=algorithm:patience
+ call assert_equal('internal,filler,algorithm:patience', &diffopt)
+
+ set diffopt&
+
+ " Multiple items with the same key (set via :let)
+ " += with different value removes all items with the same key
+ let &lcs = 'eol:$,multispace:yY,space:x,multispace:XY'
+ set lcs+=multispace:AB
+ call assert_equal('eol:$,space:x,multispace:AB', &lcs)
+
+ " += with exact duplicate keeps it and removes others with the same key
+ let &lcs = 'eol:$,multispace:XY,space:x,multispace:XY'
+ set lcs+=multispace:XY
+ call assert_equal('eol:$,multispace:XY,space:x', &lcs)
+
+ " -= removes all items with the same key
+ let &lcs = 'eol:$,multispace:yY,space:x,multispace:XY'
+ set lcs-=multispace:
+ call assert_equal('eol:$,space:x', &lcs)
+
+ " ^= with different value removes all items and prepends
+ let &lcs = 'eol:$,multispace:yY,space:x,multispace:XY'
+ set lcs^=multispace:AB
+ call assert_equal('multispace:AB,eol:$,space:x', &lcs)
+
+ set lcs&
+endfunc
+
" vim: shiftwidth=2 sts=2 expandtab
diff --git a/src/version.c b/src/version.c
index ca6585775..50918467f 100644
--- a/src/version.c
+++ b/src/version.c
@@ -734,6 +734,8 @@ static char *(features[]) =
static int included_patches[] =
{ /* Add new patch number below this line */
+/**/
+ 223,
/**/
222,
/**/
--
--
You received this message from the "vim_dev" maillist.
Do not top-post! Type your reply below the text you are replying to.
For more information, visit http://www.vim.org/maillist.php
---
You received this message because you are subscribed to the Google Groups
"vim_dev" group.
To unsubscribe from this group and stop receiving emails from it, send an email
to [email protected].
To view this discussion visit
https://groups.google.com/d/msgid/vim_dev/E1w4Lgi-000gHV-0x%40256bit.org.