1//! ## When to create a migration and why?
2//! A migration is necessary when keymap actions or settings are renamed or transformed (e.g., from an array to a string, a string to an array, a boolean to an enum, etc.).
3//!
4//! This ensures that users with outdated settings are automatically updated to use the corresponding new settings internally.
5//! It also provides a quick way to migrate their existing settings to the latest state using button in UI.
6//!
7//! ## How to create a migration?
8//! Migrations use Tree-sitter to query commonly used patterns, such as actions with a string or actions with an array where the second argument is an object, etc.
9//! Once queried, *you can filter out the modified items* and write the replacement logic.
10//!
11//! You *must not* modify previous migrations; always create new ones instead.
12//! This is important because if a user is in an intermediate state, they can smoothly transition to the latest state.
13//! Modifying existing migrations means they will only work for users upgrading from version x-1 to x, but not from x-2 to x, and so on, where x is the latest version.
14//!
15//! You only need to write replacement logic for x-1 to x because you can be certain that, internally, every user will be at x-1, regardless of their on disk state.
16
17use anyhow::{Context, Result};
18use std::{cmp::Reverse, ops::Range, sync::LazyLock};
19use streaming_iterator::StreamingIterator;
20use tree_sitter::{Query, QueryMatch};
21
22use patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
23
24mod migrations;
25mod patterns;
26
27fn migrate(text: &str, patterns: MigrationPatterns, query: &Query) -> Result<Option<String>> {
28 let mut parser = tree_sitter::Parser::new();
29 parser.set_language(&tree_sitter_json::LANGUAGE.into())?;
30 let syntax_tree = parser
31 .parse(&text, None)
32 .context("failed to parse settings")?;
33
34 let mut cursor = tree_sitter::QueryCursor::new();
35 let mut matches = cursor.matches(query, syntax_tree.root_node(), text.as_bytes());
36
37 let mut edits = vec![];
38 while let Some(mat) = matches.next() {
39 if let Some((_, callback)) = patterns.get(mat.pattern_index) {
40 edits.extend(callback(&text, &mat, query));
41 }
42 }
43
44 edits.sort_by_key(|(range, _)| (range.start, Reverse(range.end)));
45 edits.dedup_by(|(range_b, _), (range_a, _)| {
46 range_a.contains(&range_b.start) || range_a.contains(&range_b.end)
47 });
48
49 if edits.is_empty() {
50 Ok(None)
51 } else {
52 let mut new_text = text.to_string();
53 for (range, replacement) in edits.iter().rev() {
54 new_text.replace_range(range.clone(), replacement);
55 }
56 if new_text == text {
57 log::error!(
58 "Edits computed for configuration migration do not cause a change: {:?}",
59 edits
60 );
61 Ok(None)
62 } else {
63 Ok(Some(new_text))
64 }
65 }
66}
67
68fn run_migrations(
69 text: &str,
70 migrations: &[(MigrationPatterns, &Query)],
71) -> Result<Option<String>> {
72 let mut current_text = text.to_string();
73 let mut result: Option<String> = None;
74 for (patterns, query) in migrations.iter() {
75 if let Some(migrated_text) = migrate(¤t_text, patterns, query)? {
76 current_text = migrated_text.clone();
77 result = Some(migrated_text);
78 }
79 }
80 Ok(result.filter(|new_text| text != new_text))
81}
82
83pub fn migrate_keymap(text: &str) -> Result<Option<String>> {
84 let migrations: &[(MigrationPatterns, &Query)] = &[
85 (
86 migrations::m_2025_01_29::KEYMAP_PATTERNS,
87 &KEYMAP_QUERY_2025_01_29,
88 ),
89 (
90 migrations::m_2025_01_30::KEYMAP_PATTERNS,
91 &KEYMAP_QUERY_2025_01_30,
92 ),
93 (
94 migrations::m_2025_03_03::KEYMAP_PATTERNS,
95 &KEYMAP_QUERY_2025_03_03,
96 ),
97 (
98 migrations::m_2025_03_06::KEYMAP_PATTERNS,
99 &KEYMAP_QUERY_2025_03_06,
100 ),
101 ];
102 run_migrations(text, migrations)
103}
104
105pub fn migrate_settings(text: &str) -> Result<Option<String>> {
106 let migrations: &[(MigrationPatterns, &Query)] = &[
107 (
108 migrations::m_2025_01_02::SETTINGS_PATTERNS,
109 &SETTINGS_QUERY_2025_01_02,
110 ),
111 (
112 migrations::m_2025_01_29::SETTINGS_PATTERNS,
113 &SETTINGS_QUERY_2025_01_29,
114 ),
115 (
116 migrations::m_2025_01_30::SETTINGS_PATTERNS,
117 &SETTINGS_QUERY_2025_01_30,
118 ),
119 (
120 migrations::m_2025_03_29::SETTINGS_PATTERNS,
121 &SETTINGS_QUERY_2025_03_29,
122 ),
123 (
124 migrations::m_2025_04_15::SETTINGS_PATTERNS,
125 &SETTINGS_QUERY_2025_04_15,
126 ),
127 ];
128 run_migrations(text, migrations)
129}
130
131pub fn migrate_edit_prediction_provider_settings(text: &str) -> Result<Option<String>> {
132 migrate(
133 &text,
134 &[(
135 SETTINGS_NESTED_KEY_VALUE_PATTERN,
136 migrations::m_2025_01_29::replace_edit_prediction_provider_setting,
137 )],
138 &EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY,
139 )
140}
141
142pub type MigrationPatterns = &'static [(
143 &'static str,
144 fn(&str, &QueryMatch, &Query) -> Option<(Range<usize>, String)>,
145)];
146
147macro_rules! define_query {
148 ($var_name:ident, $patterns_path:path) => {
149 static $var_name: LazyLock<Query> = LazyLock::new(|| {
150 Query::new(
151 &tree_sitter_json::LANGUAGE.into(),
152 &$patterns_path
153 .iter()
154 .map(|pattern| pattern.0)
155 .collect::<String>(),
156 )
157 .unwrap()
158 });
159 };
160}
161
162// keymap
163define_query!(
164 KEYMAP_QUERY_2025_01_29,
165 migrations::m_2025_01_29::KEYMAP_PATTERNS
166);
167define_query!(
168 KEYMAP_QUERY_2025_01_30,
169 migrations::m_2025_01_30::KEYMAP_PATTERNS
170);
171define_query!(
172 KEYMAP_QUERY_2025_03_03,
173 migrations::m_2025_03_03::KEYMAP_PATTERNS
174);
175define_query!(
176 KEYMAP_QUERY_2025_03_06,
177 migrations::m_2025_03_06::KEYMAP_PATTERNS
178);
179
180// settings
181define_query!(
182 SETTINGS_QUERY_2025_01_02,
183 migrations::m_2025_01_02::SETTINGS_PATTERNS
184);
185define_query!(
186 SETTINGS_QUERY_2025_01_29,
187 migrations::m_2025_01_29::SETTINGS_PATTERNS
188);
189define_query!(
190 SETTINGS_QUERY_2025_01_30,
191 migrations::m_2025_01_30::SETTINGS_PATTERNS
192);
193define_query!(
194 SETTINGS_QUERY_2025_03_29,
195 migrations::m_2025_03_29::SETTINGS_PATTERNS
196);
197define_query!(
198 SETTINGS_QUERY_2025_04_15,
199 migrations::m_2025_04_15::SETTINGS_PATTERNS
200);
201
202// custom query
203static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
204 Query::new(
205 &tree_sitter_json::LANGUAGE.into(),
206 SETTINGS_NESTED_KEY_VALUE_PATTERN,
207 )
208 .unwrap()
209});
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn assert_migrate_keymap(input: &str, output: Option<&str>) {
216 let migrated = migrate_keymap(&input).unwrap();
217 pretty_assertions::assert_eq!(migrated.as_deref(), output);
218 }
219
220 fn assert_migrate_settings(input: &str, output: Option<&str>) {
221 let migrated = migrate_settings(&input).unwrap();
222 pretty_assertions::assert_eq!(migrated.as_deref(), output);
223 }
224
225 #[test]
226 fn test_replace_array_with_single_string() {
227 assert_migrate_keymap(
228 r#"
229 [
230 {
231 "bindings": {
232 "cmd-1": ["workspace::ActivatePaneInDirection", "Up"]
233 }
234 }
235 ]
236 "#,
237 Some(
238 r#"
239 [
240 {
241 "bindings": {
242 "cmd-1": "workspace::ActivatePaneUp"
243 }
244 }
245 ]
246 "#,
247 ),
248 )
249 }
250
251 #[test]
252 fn test_replace_action_argument_object_with_single_value() {
253 assert_migrate_keymap(
254 r#"
255 [
256 {
257 "bindings": {
258 "cmd-1": ["editor::FoldAtLevel", { "level": 1 }]
259 }
260 }
261 ]
262 "#,
263 Some(
264 r#"
265 [
266 {
267 "bindings": {
268 "cmd-1": ["editor::FoldAtLevel", 1]
269 }
270 }
271 ]
272 "#,
273 ),
274 )
275 }
276
277 #[test]
278 fn test_replace_action_argument_object_with_single_value_2() {
279 assert_migrate_keymap(
280 r#"
281 [
282 {
283 "bindings": {
284 "cmd-1": ["vim::PushOperator", { "Object": { "some" : "value" } }]
285 }
286 }
287 ]
288 "#,
289 Some(
290 r#"
291 [
292 {
293 "bindings": {
294 "cmd-1": ["vim::PushObject", { "some" : "value" }]
295 }
296 }
297 ]
298 "#,
299 ),
300 )
301 }
302
303 #[test]
304 fn test_rename_string_action() {
305 assert_migrate_keymap(
306 r#"
307 [
308 {
309 "bindings": {
310 "cmd-1": "inline_completion::ToggleMenu"
311 }
312 }
313 ]
314 "#,
315 Some(
316 r#"
317 [
318 {
319 "bindings": {
320 "cmd-1": "edit_prediction::ToggleMenu"
321 }
322 }
323 ]
324 "#,
325 ),
326 )
327 }
328
329 #[test]
330 fn test_rename_context_key() {
331 assert_migrate_keymap(
332 r#"
333 [
334 {
335 "context": "Editor && inline_completion && !showing_completions"
336 }
337 ]
338 "#,
339 Some(
340 r#"
341 [
342 {
343 "context": "Editor && edit_prediction && !showing_completions"
344 }
345 ]
346 "#,
347 ),
348 )
349 }
350
351 #[test]
352 fn test_incremental_migrations() {
353 // Here string transforms to array internally. Then, that array transforms back to string.
354 assert_migrate_keymap(
355 r#"
356 [
357 {
358 "bindings": {
359 "ctrl-q": "editor::GoToHunk", // should remain same
360 "ctrl-w": "editor::GoToPrevHunk", // should rename
361 "ctrl-q": ["editor::GoToHunk", { "center_cursor": true }], // should transform
362 "ctrl-w": ["editor::GoToPreviousHunk", { "center_cursor": true }] // should transform
363 }
364 }
365 ]
366 "#,
367 Some(
368 r#"
369 [
370 {
371 "bindings": {
372 "ctrl-q": "editor::GoToHunk", // should remain same
373 "ctrl-w": "editor::GoToPreviousHunk", // should rename
374 "ctrl-q": "editor::GoToHunk", // should transform
375 "ctrl-w": "editor::GoToPreviousHunk" // should transform
376 }
377 }
378 ]
379 "#,
380 ),
381 )
382 }
383
384 #[test]
385 fn test_action_argument_snake_case() {
386 // First performs transformations, then replacements
387 assert_migrate_keymap(
388 r#"
389 [
390 {
391 "bindings": {
392 "cmd-1": ["vim::PushOperator", { "Object": { "around": false } }],
393 "cmd-3": ["pane::CloseActiveItem", { "saveIntent": "saveAll" }],
394 "cmd-2": ["vim::NextWordStart", { "ignorePunctuation": true }],
395 "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
396 }
397 }
398 ]
399 "#,
400 Some(
401 r#"
402 [
403 {
404 "bindings": {
405 "cmd-1": ["vim::PushObject", { "around": false }],
406 "cmd-3": ["pane::CloseActiveItem", { "save_intent": "save_all" }],
407 "cmd-2": ["vim::NextWordStart", { "ignore_punctuation": true }],
408 "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
409 }
410 }
411 ]
412 "#,
413 ),
414 )
415 }
416
417 #[test]
418 fn test_replace_setting_name() {
419 assert_migrate_settings(
420 r#"
421 {
422 "show_inline_completions_in_menu": true,
423 "show_inline_completions": true,
424 "inline_completions_disabled_in": ["string"],
425 "inline_completions": { "some" : "value" }
426 }
427 "#,
428 Some(
429 r#"
430 {
431 "show_edit_predictions_in_menu": true,
432 "show_edit_predictions": true,
433 "edit_predictions_disabled_in": ["string"],
434 "edit_predictions": { "some" : "value" }
435 }
436 "#,
437 ),
438 )
439 }
440
441 #[test]
442 fn test_nested_string_replace_for_settings() {
443 assert_migrate_settings(
444 r#"
445 {
446 "features": {
447 "inline_completion_provider": "zed"
448 },
449 }
450 "#,
451 Some(
452 r#"
453 {
454 "features": {
455 "edit_prediction_provider": "zed"
456 },
457 }
458 "#,
459 ),
460 )
461 }
462
463 #[test]
464 fn test_replace_settings_in_languages() {
465 assert_migrate_settings(
466 r#"
467 {
468 "languages": {
469 "Astro": {
470 "show_inline_completions": true
471 }
472 }
473 }
474 "#,
475 Some(
476 r#"
477 {
478 "languages": {
479 "Astro": {
480 "show_edit_predictions": true
481 }
482 }
483 }
484 "#,
485 ),
486 )
487 }
488
489 #[test]
490 fn test_replace_settings_value() {
491 assert_migrate_settings(
492 r#"
493 {
494 "scrollbar": {
495 "diagnostics": true
496 },
497 "chat_panel": {
498 "button": true
499 }
500 }
501 "#,
502 Some(
503 r#"
504 {
505 "scrollbar": {
506 "diagnostics": "all"
507 },
508 "chat_panel": {
509 "button": "always"
510 }
511 }
512 "#,
513 ),
514 )
515 }
516
517 #[test]
518 fn test_replace_settings_name_and_value() {
519 assert_migrate_settings(
520 r#"
521 {
522 "tabs": {
523 "always_show_close_button": true
524 }
525 }
526 "#,
527 Some(
528 r#"
529 {
530 "tabs": {
531 "show_close_button": "always"
532 }
533 }
534 "#,
535 ),
536 )
537 }
538
539 #[test]
540 fn test_replace_bash_with_terminal_in_profiles() {
541 assert_migrate_settings(
542 r#"
543 {
544 "assistant": {
545 "profiles": {
546 "custom": {
547 "name": "Custom",
548 "tools": {
549 "bash": true,
550 "diagnostics": true
551 }
552 }
553 }
554 }
555 }
556 "#,
557 Some(
558 r#"
559 {
560 "assistant": {
561 "profiles": {
562 "custom": {
563 "name": "Custom",
564 "tools": {
565 "terminal": true,
566 "diagnostics": true
567 }
568 }
569 }
570 }
571 }
572 "#,
573 ),
574 )
575 }
576
577 #[test]
578 fn test_replace_bash_false_with_terminal_in_profiles() {
579 assert_migrate_settings(
580 r#"
581 {
582 "assistant": {
583 "profiles": {
584 "custom": {
585 "name": "Custom",
586 "tools": {
587 "bash": false,
588 "diagnostics": true
589 }
590 }
591 }
592 }
593 }
594 "#,
595 Some(
596 r#"
597 {
598 "assistant": {
599 "profiles": {
600 "custom": {
601 "name": "Custom",
602 "tools": {
603 "terminal": false,
604 "diagnostics": true
605 }
606 }
607 }
608 }
609 }
610 "#,
611 ),
612 )
613 }
614
615 #[test]
616 fn test_no_bash_in_profiles() {
617 assert_migrate_settings(
618 r#"
619 {
620 "assistant": {
621 "profiles": {
622 "custom": {
623 "name": "Custom",
624 "tools": {
625 "diagnostics": true,
626 "path_search": true,
627 "read_file": true
628 }
629 }
630 }
631 }
632 }
633 "#,
634 None,
635 )
636 }
637}