From: Hirohito Higashi Date: Sun, 22 Mar 2026 16:08:01 +0000 (+0000) Subject: patch 9.2.0223: Option handling for key:value suboptions is limited X-Git-Tag: v9.2.0223^0 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=e2f4e18437074868d89ed5c368af8c55ddc394e1;p=thirdparty%2Fvim.git 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) Signed-off-by: Hirohito Higashi Signed-off-by: Christian Brabandt --- diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index bddfa99211..d286467787 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 e9a949e371..3168994851 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 60af090337..4544652993 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 40733fdff5..a40c4a77f4 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 fd0e146b23..741b20d9b4 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"), "\n")) + + " 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"), "\n")) + " 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"), "\n")) " 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 8097a3f04a..d33c062528 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 ca65857756..50918467f6 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, /**/