Fix code actions migration (#40303)

Closes #40270

Release Notes:

- Fixed an issue with the settings migration to flatten `code_actions`
format steps where comments would cause enabled code actions to be
omitted from the migrated settings. If you were effected, restoring the
settings file backup and allowing the migration to re-run will result in
a valid settings file
- Fixed an issue where automated settings and keymap file updates would
occasionally assume 4-space indentation
This commit is contained in:
Ben Kunkle
2025-10-16 08:13:34 -05:00
committed by GitHub
parent c37a2f885a
commit ea6853d35c
10 changed files with 786 additions and 538 deletions

View File

@@ -23,7 +23,9 @@ use gpui::{
use language::{Language, LanguageConfig, ToOffset as _};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{CompletionDisplayOptions, Project};
use settings::{BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets};
use settings::{
BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size,
};
use ui::{
ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Indicator,
Modal, ModalFooter, ModalHeader, ParentElement as _, PopoverMenu, Render, Section,
@@ -1198,13 +1200,12 @@ impl KeymapEditor {
else {
return;
};
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
self.previous_edit = Some(PreviousEdit::ScrollBarOffset(
self.table_interaction_state.read(cx).scroll_offset(),
));
let keyboard_mapper = cx.keyboard_mapper().clone();
cx.spawn(async move |_, _| {
remove_keybinding(to_remove, &fs, tab_size, keyboard_mapper.as_ref()).await
remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await
})
.detach_and_notify_err(window, cx);
}
@@ -2288,7 +2289,6 @@ impl KeybindingEditorModal {
fn save(&mut self, cx: &mut Context<Self>) -> Result<(), InputError> {
let existing_keybind = self.editing_keybind.clone();
let fs = self.fs.clone();
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
let mut new_keystrokes = self.validate_keystrokes(cx).map_err(InputError::error)?;
new_keystrokes
@@ -2367,7 +2367,6 @@ impl KeybindingEditorModal {
&action_mapping,
new_action_args.as_deref(),
&fs,
tab_size,
keyboard_mapper.as_ref(),
)
.await
@@ -3019,13 +3018,14 @@ async fn save_keybinding_update(
action_mapping: &ActionMapping,
new_args: Option<&str>,
fs: &Arc<dyn Fs>,
tab_size: usize,
keyboard_mapper: &dyn PlatformKeyboardMapper,
) -> anyhow::Result<()> {
let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await
.context("Failed to load keymap file")?;
let tab_size = infer_json_indent_size(&keymap_contents);
let existing_keystrokes = existing.keystrokes().unwrap_or_default();
let existing_context = existing.context().and_then(KeybindContextString::local_str);
let existing_args = existing
@@ -3089,7 +3089,6 @@ async fn save_keybinding_update(
async fn remove_keybinding(
existing: ProcessedBinding,
fs: &Arc<dyn Fs>,
tab_size: usize,
keyboard_mapper: &dyn PlatformKeyboardMapper,
) -> anyhow::Result<()> {
let Some(keystrokes) = existing.keystrokes() else {
@@ -3098,6 +3097,7 @@ async fn remove_keybinding(
let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await
.context("Failed to load keymap file")?;
let tab_size = infer_json_indent_size(&keymap_contents);
let operation = settings::KeybindUpdateOperation::Remove {
target: settings::KeybindUpdateTarget {

View File

@@ -103,7 +103,7 @@ pub(crate) mod m_2025_07_08 {
pub(crate) mod m_2025_10_01 {
mod settings;
pub(crate) use settings::SETTINGS_PATTERNS;
pub(crate) use settings::flatten_code_actions_formatters;
}
pub(crate) mod m_2025_10_02 {

View File

@@ -1,109 +1,74 @@
use std::ops::Range;
use tree_sitter::{Query, QueryMatch};
use crate::patterns::migrate_language_setting;
use anyhow::Result;
use serde_json::Value;
use crate::MigrationPatterns;
pub const SETTINGS_PATTERNS: MigrationPatterns =
&[(FORMATTER_PATTERN, migrate_code_action_formatters)];
const FORMATTER_PATTERN: &str = r#"
(object
(pair
key: (string (string_content) @formatter) (#any-of? @formatter "formatter" "format_on_save")
value: [
(array
(object
(pair
key: (string (string_content) @code-actions-key) (#eq? @code-actions-key "code_actions")
value: (object
((pair) @code-action ","?)*
)
)
) @code-actions-obj
) @formatter-array
(object
(pair
key: (string (string_content) @code-actions-key) (#eq? @code-actions-key "code_actions")
value: (object
((pair) @code-action ","?)*
)
)
) @code-actions-obj
]
)
)
"#;
pub fn migrate_code_action_formatters(
contents: &str,
mat: &QueryMatch,
query: &Query,
) -> Option<(Range<usize>, String)> {
let code_actions_obj_ix = query.capture_index_for_name("code-actions-obj")?;
let code_actions_obj_node = mat.nodes_for_capture_index(code_actions_obj_ix).next()?;
let mut code_actions = vec![];
let code_actions_ix = query.capture_index_for_name("code-action")?;
for code_action_node in mat.nodes_for_capture_index(code_actions_ix) {
let Some(enabled) = code_action_node
.child_by_field_name("value")
.map(|n| n.kind() != "false")
else {
continue;
pub fn flatten_code_actions_formatters(value: &mut Value) -> Result<()> {
migrate_language_setting(value, |value, _path| {
let Some(obj) = value.as_object_mut() else {
return Ok(());
};
if !enabled {
continue;
}
let Some(name) = code_action_node
.child_by_field_name("key")
.and_then(|n| n.child(1))
.map(|n| &contents[n.byte_range()])
else {
continue;
};
code_actions.push(name);
}
for key in ["formatter", "format_on_save"] {
let Some(formatter) = obj.get_mut(key) else {
continue;
};
let new_formatter = match formatter {
Value::Array(arr) => {
let mut new_arr = Vec::new();
let mut found_code_actions = false;
for item in arr {
let Some(obj) = item.as_object() else {
new_arr.push(item.clone());
continue;
};
let code_actions_obj = obj
.get("code_actions")
.and_then(|code_actions| code_actions.as_object());
let Some(code_actions) = code_actions_obj else {
new_arr.push(item.clone());
continue;
};
found_code_actions = true;
for (name, enabled) in code_actions {
if !enabled.as_bool().unwrap_or(true) {
continue;
}
new_arr.push(serde_json::json!({
"code_action": name
}));
}
}
if !found_code_actions {
continue;
}
Value::Array(new_arr)
}
Value::Object(obj) => {
let mut new_arr = Vec::new();
let code_actions_obj = obj
.get("code_actions")
.and_then(|code_actions| code_actions.as_object());
let Some(code_actions) = code_actions_obj else {
continue;
};
for (name, enabled) in code_actions {
if !enabled.as_bool().unwrap_or(true) {
continue;
}
new_arr.push(serde_json::json!({
"code_action": name
}));
}
if new_arr.len() == 1 {
new_arr.pop().unwrap()
} else {
Value::Array(new_arr)
}
}
_ => continue,
};
let indent = query
.capture_index_for_name("formatter")
.and_then(|ix| mat.nodes_for_capture_index(ix).next())
.map(|node| node.start_position().column + 1)
.unwrap_or(2);
let mut code_actions_str = code_actions
.into_iter()
.map(|code_action| format!(r#"{{ "code_action": "{}" }}"#, code_action))
.collect::<Vec<_>>()
.join(&format!(",\n{}", " ".repeat(indent)));
let is_array = query
.capture_index_for_name("formatter-array")
.map(|ix| mat.nodes_for_capture_index(ix).count() > 0)
.unwrap_or(false);
if !is_array {
code_actions_str.insert_str(0, &" ".repeat(indent));
code_actions_str.insert_str(0, "[\n");
code_actions_str.push('\n');
code_actions_str.push_str(&" ".repeat(indent.saturating_sub(2)));
code_actions_str.push_str("]");
}
let mut replace_range = code_actions_obj_node.byte_range();
if is_array && code_actions_str.is_empty() {
let mut cursor = code_actions_obj_node.parent().unwrap().walk();
cursor.goto_first_child();
while cursor.node().id() != code_actions_obj_node.id() && cursor.goto_next_sibling() {}
while cursor.goto_next_sibling()
&& (cursor.node().is_extra()
|| cursor.node().is_missing()
|| cursor.node().kind() == "comment")
{}
if cursor.node().kind() == "," {
// found comma, delete up to next node
while cursor.goto_next_sibling()
&& (cursor.node().is_extra() || cursor.node().is_missing())
{}
replace_range.end = cursor.node().range().start_byte;
obj.insert(key.to_string(), new_formatter);
}
}
Some((replace_range, code_actions_str))
return Ok(());
})
}

View File

@@ -1,19 +1,10 @@
use anyhow::Result;
use serde_json::Value;
use crate::patterns::migrate_language_setting;
pub fn remove_formatters_on_save(value: &mut Value) -> Result<()> {
remove_formatters_on_save_inner(value, &[])?;
let languages = value
.as_object_mut()
.and_then(|obj| obj.get_mut("languages"))
.and_then(|languages| languages.as_object_mut());
if let Some(languages) = languages {
for (language_name, language) in languages.iter_mut() {
let path = vec!["languages", language_name];
remove_formatters_on_save_inner(language, &path)?;
}
}
Ok(())
migrate_language_setting(value, remove_formatters_on_save_inner)
}
fn remove_formatters_on_save_inner(value: &mut Value, path: &[&str]) -> Result<()> {

View File

@@ -1,19 +1,10 @@
use anyhow::Result;
use serde_json::Value;
use crate::patterns::migrate_language_setting;
pub fn remove_code_actions_on_format(value: &mut Value) -> Result<()> {
remove_code_actions_on_format_inner(value, &[])?;
let languages = value
.as_object_mut()
.and_then(|obj| obj.get_mut("languages"))
.and_then(|languages| languages.as_object_mut());
if let Some(languages) = languages {
for (language_name, language) in languages.iter_mut() {
let path = vec!["languages", language_name];
remove_code_actions_on_format_inner(language, &path)?;
}
}
Ok(())
migrate_language_setting(value, remove_code_actions_on_format_inner)
}
fn remove_code_actions_on_format_inner(value: &mut Value, path: &[&str]) -> Result<()> {

View File

@@ -74,6 +74,7 @@ fn run_migrations(text: &str, migrations: &[MigrationType]) -> Result<Option<Str
let mut current_text = text.to_string();
let mut result: Option<String> = None;
let json_indent_size = settings::infer_json_indent_size(&current_text);
for migration in migrations.iter() {
let migrated_text = match migration {
MigrationType::TreeSitter(patterns, query) => migrate(&current_text, patterns, query)?,
@@ -92,7 +93,7 @@ fn run_migrations(text: &str, migrations: &[MigrationType]) -> Result<Option<Str
settings::update_value_in_json_text(
&mut current,
&mut vec![],
2,
json_indent_size,
&old_value,
&new_value,
&mut edits,
@@ -204,10 +205,7 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
migrations::m_2025_07_08::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_07_08,
),
MigrationType::TreeSitter(
migrations::m_2025_10_01::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_10_01,
),
MigrationType::Json(migrations::m_2025_10_01::flatten_code_actions_formatters),
MigrationType::Json(migrations::m_2025_10_02::remove_formatters_on_save),
MigrationType::TreeSitter(
migrations::m_2025_10_03::SETTINGS_PATTERNS,
@@ -328,10 +326,6 @@ define_query!(
SETTINGS_QUERY_2025_07_08,
migrations::m_2025_07_08::SETTINGS_PATTERNS
);
define_query!(
SETTINGS_QUERY_2025_10_01,
migrations::m_2025_10_01::SETTINGS_PATTERNS
);
define_query!(
SETTINGS_QUERY_2025_10_03,
migrations::m_2025_10_03::SETTINGS_PATTERNS
@@ -351,10 +345,11 @@ mod tests {
use super::*;
use unindent::Unindent as _;
#[track_caller]
fn assert_migrated_correctly(migrated: Option<String>, expected: Option<&str>) {
match (&migrated, &expected) {
(Some(migrated), Some(expected)) => {
pretty_assertions::assert_str_eq!(migrated, expected);
pretty_assertions::assert_str_eq!(expected, migrated);
}
_ => {
pretty_assertions::assert_eq!(migrated.as_deref(), expected);
@@ -372,6 +367,7 @@ mod tests {
assert_migrated_correctly(migrated, output);
}
#[track_caller]
fn assert_migrate_settings_with_migrations(
migrations: &[MigrationType],
input: &str,
@@ -1343,24 +1339,28 @@ mod tests {
fn test_flatten_code_action_formatters_basic_array() {
assert_migrate_settings(
&r#"{
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
}
]
}"#
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
}
]
}"#
.unindent(),
Some(
&r#"{
"formatter": [
{ "code_action": "included-1" },
{ "code_action": "included-2" }
]
}"#
"formatter": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
}
]
}"#
.unindent(),
),
);
@@ -1370,21 +1370,25 @@ mod tests {
fn test_flatten_code_action_formatters_basic_object() {
assert_migrate_settings(
&r#"{
"formatter": {
"code_actions": {
"included-1": true,
"excluded": false,
"included-2": true
}
}
}"#
"formatter": {
"code_actions": {
"included-1": true,
"excluded": false,
"included-2": true
}
}
}"#
.unindent(),
Some(
&r#"{
"formatter": [
{ "code_action": "included-1" },
{ "code_action": "included-2" }
]
"formatter": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
}
]
}"#
.unindent(),
),
@@ -1394,47 +1398,57 @@ mod tests {
#[test]
fn test_flatten_code_action_formatters_array_with_multiple_action_blocks() {
assert_migrate_settings(
r#"{
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}"#,
&r#"{
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}"#
.unindent(),
Some(
r#"{
"formatter": [
{ "code_action": "included-1" },
{ "code_action": "included-2" },
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
]
}"#,
&r#"{
"formatter": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
},
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
]
}"#
.unindent(),
),
);
}
@@ -1443,55 +1457,63 @@ mod tests {
fn test_flatten_code_action_formatters_array_with_multiple_action_blocks_in_languages() {
assert_migrate_settings(
&r#"{
"languages": {
"Rust": {
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}
"languages": {
"Rust": {
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
}"#
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}
}
}"#
.unindent(),
Some(
&r#"{
"languages": {
"Rust": {
"formatter": [
{ "code_action": "included-1" },
{ "code_action": "included-2" },
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
]
}
}
}"#
"languages": {
"Rust": {
"formatter": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
},
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
]
}
}
}"#
.unindent(),
),
);
@@ -1502,100 +1524,120 @@ mod tests {
{
assert_migrate_settings(
&r#"{
"formatter": {
"code_actions": {
"default-1": true,
"default-2": true,
"default-3": true,
"default-4": true,
}
},
"languages": {
"Rust": {
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
},
"Python": {
"formatter": [
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}
"formatter": {
"code_actions": {
"default-1": true,
"default-2": true,
"default-3": true,
"default-4": true,
}
},
"languages": {
"Rust": {
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
}"#
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
},
"Python": {
"formatter": [
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}
}
}"#
.unindent(),
Some(
&r#"{
"formatter": [
{ "code_action": "default-1" },
{ "code_action": "default-2" },
{ "code_action": "default-3" },
{ "code_action": "default-4" }
],
"languages": {
"Rust": {
"formatter": [
{ "code_action": "included-1" },
{ "code_action": "included-2" },
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
]
},
"Python": {
"formatter": [
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
]
}
}
}"#
"formatter": [
{
"code_action": "default-1"
},
{
"code_action": "default-2"
},
{
"code_action": "default-3"
},
{
"code_action": "default-4"
}
],
"languages": {
"Rust": {
"formatter": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
},
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
]
},
"Python": {
"formatter": [
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
]
}
}
}"#
.unindent(),
),
);
@@ -1604,153 +1646,185 @@ mod tests {
#[test]
fn test_flatten_code_action_formatters_array_with_format_on_save_and_multiple_languages() {
assert_migrate_settings_with_migrations(
&[MigrationType::TreeSitter(
migrations::m_2025_10_01::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_10_01,
&[MigrationType::Json(
migrations::m_2025_10_01::flatten_code_actions_formatters,
)],
&r#"{
"formatter": {
"code_actions": {
"default-1": true,
"default-2": true,
"default-3": true,
"default-4": true,
}
},
"format_on_save": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
],
"languages": {
"Rust": {
"format_on_save": "prettier",
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
},
"Python": {
"format_on_save": {
"code_actions": {
"on-save-1": true,
"on-save-2": true,
}
},
"formatter": [
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}
"formatter": {
"code_actions": {
"default-1": true,
"default-2": true,
"default-3": true,
"default-4": true,
}
},
"format_on_save": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
],
"languages": {
"Rust": {
"format_on_save": "prettier",
"formatter": [
{
"code_actions": {
"included-1": true,
"included-2": true,
"excluded": false,
}
}"#
},
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
},
"Python": {
"format_on_save": {
"code_actions": {
"on-save-1": true,
"on-save-2": true,
}
},
"formatter": [
{
"language_server": "ruff"
},
{
"code_actions": {
"excluded": false,
"excluded-2": false,
}
}
// some comment
,
{
"code_actions": {
"excluded": false,
"included-3": true,
"included-4": true,
}
},
]
}
}
}"#
.unindent(),
Some(
&r#"{
"formatter": [
{ "code_action": "default-1" },
{ "code_action": "default-2" },
{ "code_action": "default-3" },
{ "code_action": "default-4" }
],
"format_on_save": [
{ "code_action": "included-1" },
{ "code_action": "included-2" },
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
],
"languages": {
"Rust": {
"format_on_save": "prettier",
"formatter": [
{ "code_action": "included-1" },
{ "code_action": "included-2" },
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
]
},
"Python": {
"format_on_save": [
{ "code_action": "on-save-1" },
{ "code_action": "on-save-2" }
],
"formatter": [
{
"language_server": "ruff"
},
{ "code_action": "included-3" },
{ "code_action": "included-4" },
]
}
}
}"#
&r#"
{
"formatter": [
{
"code_action": "default-1"
},
{
"code_action": "default-2"
},
{
"code_action": "default-3"
},
{
"code_action": "default-4"
}
],
"format_on_save": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
},
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
],
"languages": {
"Rust": {
"format_on_save": "prettier",
"formatter": [
{
"code_action": "included-1"
},
{
"code_action": "included-2"
},
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
]
},
"Python": {
"format_on_save": [
{
"code_action": "on-save-1"
},
{
"code_action": "on-save-2"
}
],
"formatter": [
{
"language_server": "ruff"
},
{
"code_action": "included-3"
},
{
"code_action": "included-4"
}
]
}
}
}"#
.unindent(),
),
);
@@ -1952,25 +2026,25 @@ mod tests {
migrations::m_2025_10_10::remove_code_actions_on_format,
)],
&r#"{
"code_actions_on_format": {
"a": true,
"b": false,
"c": true
}
}"#
"code_actions_on_format": {
"a": true,
"b": false,
"c": true
}
}"#
.unindent(),
Some(
&r#"{
"formatter": [
{
"code_action": "a"
},
{
"code_action": "c"
}
]
}
"#
"formatter": [
{
"code_action": "a"
},
{
"code_action": "c"
}
]
}
"#
.unindent(),
),
);
@@ -2163,12 +2237,12 @@ mod tests {
]
},
"Python": {
"formatter": [
{
"code_action": "source.organizeImports"
}
]
"formatter": [
{
"code_action": "source.organizeImports"
}
]
}
}
}"#
.unindent(),
@@ -2212,4 +2286,53 @@ mod tests {
),
);
}
#[test]
fn test_code_action_formatters_issue() {
assert_migrate_settings_with_migrations(
&[MigrationType::Json(
migrations::m_2025_10_01::flatten_code_actions_formatters,
)],
&r#"
{
"languages": {
"Python": {
"language_servers": ["ruff"],
"format_on_save": "on",
"formatter": [
{
"code_actions": {
// Fix all auto-fixable lint violations
"source.fixAll.ruff": true,
// Organize imports
"source.organizeImports.ruff": true
}
}
]
}
}
}"#
.unindent(),
Some(
&r#"
{
"languages": {
"Python": {
"language_servers": ["ruff"],
"format_on_save": "on",
"formatter": [
{
"code_action": "source.fixAll.ruff"
},
{
"code_action": "source.organizeImports.ruff"
}
]
}
}
}"#
.unindent(),
),
);
}
}

View File

@@ -10,4 +10,5 @@ pub(crate) use settings::{
SETTINGS_ASSISTANT_PATTERN, SETTINGS_ASSISTANT_TOOLS_PATTERN,
SETTINGS_DUPLICATED_AGENT_PATTERN, SETTINGS_EDIT_PREDICTIONS_ASSISTANT_PATTERN,
SETTINGS_LANGUAGES_PATTERN, SETTINGS_NESTED_KEY_VALUE_PATTERN, SETTINGS_ROOT_KEY_VALUE_PATTERN,
migrate_language_setting,
};

View File

@@ -108,3 +108,24 @@ pub const SETTINGS_DUPLICATED_AGENT_PATTERN: &str = r#"(document
(#eq? @agent1 "agent")
(#eq? @agent2 "agent")
)"#;
/// Migrate language settings,
/// calls `migrate_fn` with the top level object as well as all language settings under the "languages" key
/// Fails early if `migrate_fn` returns an error at any point
pub fn migrate_language_setting(
value: &mut serde_json::Value,
migrate_fn: fn(&mut serde_json::Value, path: &[&str]) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
migrate_fn(value, &[])?;
let languages = value
.as_object_mut()
.and_then(|obj| obj.get_mut("languages"))
.and_then(|languages| languages.as_object_mut());
if let Some(languages) = languages {
for (language_name, language) in languages.iter_mut() {
let path = vec!["languages", language_name];
migrate_fn(language, &path)?;
}
}
Ok(())
}

View File

@@ -262,8 +262,8 @@ pub fn replace_value_in_json_text<T: AsRef<str>>(
} else {
// We don't have the key, construct the nested objects
let new_value = construct_json_value(&key_path[depth..], new_value);
let indent_prefix_len = 4 * depth;
let mut new_val = to_pretty_json(&new_value, 4, indent_prefix_len);
let indent_prefix_len = tab_size * depth;
let mut new_val = to_pretty_json(&new_value, tab_size, indent_prefix_len);
if depth == 0 {
new_val.push('\n');
}
@@ -628,6 +628,100 @@ pub fn append_top_level_array_value_in_json_text(
}
}
/// Infers the indentation size used in JSON text by analyzing the tree structure.
/// Returns the detected indent size, or a default of 2 if no indentation is found.
pub fn infer_json_indent_size(text: &str) -> usize {
const MAX_INDENT_SIZE: usize = 64;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_json::LANGUAGE.into())
.unwrap();
let Some(syntax_tree) = parser.parse(text, None) else {
return 4;
};
let mut cursor = syntax_tree.walk();
let mut indent_counts = [0u32; MAX_INDENT_SIZE];
// Traverse the tree to find indentation patterns
fn visit_node(
cursor: &mut tree_sitter::TreeCursor,
indent_counts: &mut [u32; MAX_INDENT_SIZE],
depth: usize,
) {
if depth >= 3 {
return;
}
let node = cursor.node();
let node_kind = node.kind();
// For objects and arrays, check the indentation of their first content child
if matches!(node_kind, "object" | "array") {
let container_column = node.start_position().column;
let container_row = node.start_position().row;
if cursor.goto_first_child() {
// Skip the opening bracket
loop {
let child = cursor.node();
let child_kind = child.kind();
// Look for the first actual content (pair for objects, value for arrays)
if (node_kind == "object" && child_kind == "pair")
|| (node_kind == "array"
&& !matches!(child_kind, "[" | "]" | "," | "comment"))
{
let child_column = child.start_position().column;
let child_row = child.start_position().row;
// Only count if the child is on a different line
if child_row > container_row && child_column > container_column {
let indent = child_column - container_column;
if indent > 0 && indent < MAX_INDENT_SIZE {
indent_counts[indent] += 1;
}
}
break;
}
if !cursor.goto_next_sibling() {
break;
}
}
cursor.goto_parent();
}
}
// Recurse to children
if cursor.goto_first_child() {
loop {
visit_node(cursor, indent_counts, depth + 1);
if !cursor.goto_next_sibling() {
break;
}
}
cursor.goto_parent();
}
}
visit_node(&mut cursor, &mut indent_counts, 0);
// Find the indent size with the highest count
let mut max_count = 0;
let mut max_indent = 4;
for (indent, &count) in indent_counts.iter().enumerate() {
if count > max_count {
max_count = count;
max_indent = indent;
}
}
if max_count == 0 { 2 } else { max_indent }
}
pub fn to_pretty_json(
value: &impl Serialize,
indent_size: usize,
@@ -2486,4 +2580,69 @@ mod tests {
.unindent(),
)
}
#[test]
fn test_infer_json_indent_size() {
let json_2_spaces = r#"{
"key1": "value1",
"nested": {
"key2": "value2",
"array": [
1,
2,
3
]
}
}"#;
assert_eq!(infer_json_indent_size(json_2_spaces), 2);
let json_4_spaces = r#"{
"key1": "value1",
"nested": {
"key2": "value2",
"array": [
1,
2,
3
]
}
}"#;
assert_eq!(infer_json_indent_size(json_4_spaces), 4);
let json_8_spaces = r#"{
"key1": "value1",
"nested": {
"key2": "value2"
}
}"#;
assert_eq!(infer_json_indent_size(json_8_spaces), 8);
let json_single_line = r#"{"key": "value", "nested": {"inner": "data"}}"#;
assert_eq!(infer_json_indent_size(json_single_line), 2);
let json_empty = r#"{}"#;
assert_eq!(infer_json_indent_size(json_empty), 2);
let json_array = r#"[
{
"id": 1,
"name": "first"
},
{
"id": 2,
"name": "second"
}
]"#;
assert_eq!(infer_json_indent_size(json_array), 2);
let json_mixed = r#"{
"a": {
"b": {
"c": "value"
}
},
"d": "value2"
}"#;
assert_eq!(infer_json_indent_size(json_mixed), 2);
}
}

View File

@@ -33,6 +33,7 @@ pub type EditorconfigProperties = ec4rs::Properties;
use crate::{
ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent,
LanguageToSettingsMap, SettingsJsonSchemaParams, ThemeName, VsCodeSettings, WorktreeId,
infer_json_indent_size,
merge_from::MergeFrom,
parse_json_with_comments,
settings_content::{
@@ -637,7 +638,7 @@ impl SettingsStore {
let mut key_path = Vec::new();
let mut edits = Vec::new();
let tab_size = self.json_tab_size();
let tab_size = infer_json_indent_size(&text);
let mut text = text.to_string();
update_value_in_json_text(
&mut text,
@@ -650,10 +651,6 @@ impl SettingsStore {
edits
}
pub fn json_tab_size(&self) -> usize {
2
}
/// Sets the default settings via a JSON string.
///
/// The string should contain a JSON object with a default value for every setting.
@@ -1540,9 +1537,9 @@ mod tests {
})
},
r#"{
"tabs": {
"git_status": true
}
"tabs": {
"git_status": true
}
}
"#
.unindent(),
@@ -1557,9 +1554,9 @@ mod tests {
.unindent(),
|settings| settings.title_bar.get_or_insert_default().show_branch_name = Some(true),
r#"{
"title_bar": {
"show_branch_name": true
}
"title_bar": {
"show_branch_name": true
}
}
"#
.unindent(),
@@ -1584,7 +1581,7 @@ mod tests {
.unindent(),
r#" { "editor.tabSize": 37 } "#.to_owned(),
r#"{
"tab_size": 37
"tab_size": 37
}
"#
.unindent(),
@@ -1637,9 +1634,9 @@ mod tests {
.unindent(),
r#"{ "workbench.editor.decorations.colors": true }"#.to_owned(),
r#"{
"tabs": {
"git_status": true
}
"tabs": {
"git_status": true
}
}
"#
.unindent(),
@@ -1655,11 +1652,11 @@ mod tests {
.unindent(),
r#"{ "editor.fontFamily": "Cascadia Code, 'Consolas', Courier New" }"#.to_owned(),
r#"{
"buffer_font_fallbacks": [
"Consolas",
"Courier New"
],
"buffer_font_family": "Cascadia Code"
"buffer_font_fallbacks": [
"Consolas",
"Courier New"
],
"buffer_font_family": "Cascadia Code"
}
"#
.unindent(),
@@ -1695,16 +1692,16 @@ mod tests {
.get_or_insert_default()
.enabled = Some(true);
});
assert_eq!(
pretty_assertions::assert_str_eq!(
actual,
r#"{
"git": {
"git": {
"inline_blame": {
"enabled": true
"enabled": true
}
}
}
}
"#
"#
.unindent()
);
}