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 as _, 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 migrations::m_2025_04_15::KEYMAP_PATTERNS,
103 &KEYMAP_QUERY_2025_04_15,
104 ),
105 ];
106 run_migrations(text, migrations)
107}
108
109pub fn migrate_settings(text: &str) -> Result<Option<String>> {
110 let migrations: &[(MigrationPatterns, &Query)] = &[
111 (
112 migrations::m_2025_01_02::SETTINGS_PATTERNS,
113 &SETTINGS_QUERY_2025_01_02,
114 ),
115 (
116 migrations::m_2025_01_29::SETTINGS_PATTERNS,
117 &SETTINGS_QUERY_2025_01_29,
118 ),
119 (
120 migrations::m_2025_01_30::SETTINGS_PATTERNS,
121 &SETTINGS_QUERY_2025_01_30,
122 ),
123 (
124 migrations::m_2025_03_29::SETTINGS_PATTERNS,
125 &SETTINGS_QUERY_2025_03_29,
126 ),
127 (
128 migrations::m_2025_04_15::SETTINGS_PATTERNS,
129 &SETTINGS_QUERY_2025_04_15,
130 ),
131 (
132 migrations::m_2025_04_21::SETTINGS_PATTERNS,
133 &SETTINGS_QUERY_2025_04_21,
134 ),
135 (
136 migrations::m_2025_04_23::SETTINGS_PATTERNS,
137 &SETTINGS_QUERY_2025_04_23,
138 ),
139 (
140 migrations::m_2025_05_05::SETTINGS_PATTERNS,
141 &SETTINGS_QUERY_2025_05_05,
142 ),
143 (
144 migrations::m_2025_05_08::SETTINGS_PATTERNS,
145 &SETTINGS_QUERY_2025_05_08,
146 ),
147 ];
148 run_migrations(text, migrations)
149}
150
151pub fn migrate_edit_prediction_provider_settings(text: &str) -> Result<Option<String>> {
152 migrate(
153 &text,
154 &[(
155 SETTINGS_NESTED_KEY_VALUE_PATTERN,
156 migrations::m_2025_01_29::replace_edit_prediction_provider_setting,
157 )],
158 &EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY,
159 )
160}
161
162pub type MigrationPatterns = &'static [(
163 &'static str,
164 fn(&str, &QueryMatch, &Query) -> Option<(Range<usize>, String)>,
165)];
166
167macro_rules! define_query {
168 ($var_name:ident, $patterns_path:path) => {
169 static $var_name: LazyLock<Query> = LazyLock::new(|| {
170 Query::new(
171 &tree_sitter_json::LANGUAGE.into(),
172 &$patterns_path
173 .iter()
174 .map(|pattern| pattern.0)
175 .collect::<String>(),
176 )
177 .unwrap()
178 });
179 };
180}
181
182// keymap
183define_query!(
184 KEYMAP_QUERY_2025_01_29,
185 migrations::m_2025_01_29::KEYMAP_PATTERNS
186);
187define_query!(
188 KEYMAP_QUERY_2025_01_30,
189 migrations::m_2025_01_30::KEYMAP_PATTERNS
190);
191define_query!(
192 KEYMAP_QUERY_2025_03_03,
193 migrations::m_2025_03_03::KEYMAP_PATTERNS
194);
195define_query!(
196 KEYMAP_QUERY_2025_03_06,
197 migrations::m_2025_03_06::KEYMAP_PATTERNS
198);
199define_query!(
200 KEYMAP_QUERY_2025_04_15,
201 migrations::m_2025_04_15::KEYMAP_PATTERNS
202);
203
204// settings
205define_query!(
206 SETTINGS_QUERY_2025_01_02,
207 migrations::m_2025_01_02::SETTINGS_PATTERNS
208);
209define_query!(
210 SETTINGS_QUERY_2025_01_29,
211 migrations::m_2025_01_29::SETTINGS_PATTERNS
212);
213define_query!(
214 SETTINGS_QUERY_2025_01_30,
215 migrations::m_2025_01_30::SETTINGS_PATTERNS
216);
217define_query!(
218 SETTINGS_QUERY_2025_03_29,
219 migrations::m_2025_03_29::SETTINGS_PATTERNS
220);
221define_query!(
222 SETTINGS_QUERY_2025_04_15,
223 migrations::m_2025_04_15::SETTINGS_PATTERNS
224);
225define_query!(
226 SETTINGS_QUERY_2025_04_21,
227 migrations::m_2025_04_21::SETTINGS_PATTERNS
228);
229define_query!(
230 SETTINGS_QUERY_2025_04_23,
231 migrations::m_2025_04_23::SETTINGS_PATTERNS
232);
233define_query!(
234 SETTINGS_QUERY_2025_05_05,
235 migrations::m_2025_05_05::SETTINGS_PATTERNS
236);
237define_query!(
238 SETTINGS_QUERY_2025_05_08,
239 migrations::m_2025_05_08::SETTINGS_PATTERNS
240);
241
242// custom query
243static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
244 Query::new(
245 &tree_sitter_json::LANGUAGE.into(),
246 SETTINGS_NESTED_KEY_VALUE_PATTERN,
247 )
248 .unwrap()
249});
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn assert_migrate_keymap(input: &str, output: Option<&str>) {
256 let migrated = migrate_keymap(&input).unwrap();
257 pretty_assertions::assert_eq!(migrated.as_deref(), output);
258 }
259
260 fn assert_migrate_settings(input: &str, output: Option<&str>) {
261 let migrated = migrate_settings(&input).unwrap();
262 pretty_assertions::assert_eq!(migrated.as_deref(), output);
263 }
264
265 #[test]
266 fn test_replace_array_with_single_string() {
267 assert_migrate_keymap(
268 r#"
269 [
270 {
271 "bindings": {
272 "cmd-1": ["workspace::ActivatePaneInDirection", "Up"]
273 }
274 }
275 ]
276 "#,
277 Some(
278 r#"
279 [
280 {
281 "bindings": {
282 "cmd-1": "workspace::ActivatePaneUp"
283 }
284 }
285 ]
286 "#,
287 ),
288 )
289 }
290
291 #[test]
292 fn test_replace_action_argument_object_with_single_value() {
293 assert_migrate_keymap(
294 r#"
295 [
296 {
297 "bindings": {
298 "cmd-1": ["editor::FoldAtLevel", { "level": 1 }]
299 }
300 }
301 ]
302 "#,
303 Some(
304 r#"
305 [
306 {
307 "bindings": {
308 "cmd-1": ["editor::FoldAtLevel", 1]
309 }
310 }
311 ]
312 "#,
313 ),
314 )
315 }
316
317 #[test]
318 fn test_replace_action_argument_object_with_single_value_2() {
319 assert_migrate_keymap(
320 r#"
321 [
322 {
323 "bindings": {
324 "cmd-1": ["vim::PushOperator", { "Object": { "some" : "value" } }]
325 }
326 }
327 ]
328 "#,
329 Some(
330 r#"
331 [
332 {
333 "bindings": {
334 "cmd-1": ["vim::PushObject", { "some" : "value" }]
335 }
336 }
337 ]
338 "#,
339 ),
340 )
341 }
342
343 #[test]
344 fn test_rename_string_action() {
345 assert_migrate_keymap(
346 r#"
347 [
348 {
349 "bindings": {
350 "cmd-1": "inline_completion::ToggleMenu"
351 }
352 }
353 ]
354 "#,
355 Some(
356 r#"
357 [
358 {
359 "bindings": {
360 "cmd-1": "edit_prediction::ToggleMenu"
361 }
362 }
363 ]
364 "#,
365 ),
366 )
367 }
368
369 #[test]
370 fn test_rename_context_key() {
371 assert_migrate_keymap(
372 r#"
373 [
374 {
375 "context": "Editor && inline_completion && !showing_completions"
376 }
377 ]
378 "#,
379 Some(
380 r#"
381 [
382 {
383 "context": "Editor && edit_prediction && !showing_completions"
384 }
385 ]
386 "#,
387 ),
388 )
389 }
390
391 #[test]
392 fn test_incremental_migrations() {
393 // Here string transforms to array internally. Then, that array transforms back to string.
394 assert_migrate_keymap(
395 r#"
396 [
397 {
398 "bindings": {
399 "ctrl-q": "editor::GoToHunk", // should remain same
400 "ctrl-w": "editor::GoToPrevHunk", // should rename
401 "ctrl-q": ["editor::GoToHunk", { "center_cursor": true }], // should transform
402 "ctrl-w": ["editor::GoToPreviousHunk", { "center_cursor": true }] // should transform
403 }
404 }
405 ]
406 "#,
407 Some(
408 r#"
409 [
410 {
411 "bindings": {
412 "ctrl-q": "editor::GoToHunk", // should remain same
413 "ctrl-w": "editor::GoToPreviousHunk", // should rename
414 "ctrl-q": "editor::GoToHunk", // should transform
415 "ctrl-w": "editor::GoToPreviousHunk" // should transform
416 }
417 }
418 ]
419 "#,
420 ),
421 )
422 }
423
424 #[test]
425 fn test_action_argument_snake_case() {
426 // First performs transformations, then replacements
427 assert_migrate_keymap(
428 r#"
429 [
430 {
431 "bindings": {
432 "cmd-1": ["vim::PushOperator", { "Object": { "around": false } }],
433 "cmd-3": ["pane::CloseActiveItem", { "saveIntent": "saveAll" }],
434 "cmd-2": ["vim::NextWordStart", { "ignorePunctuation": true }],
435 "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
436 }
437 }
438 ]
439 "#,
440 Some(
441 r#"
442 [
443 {
444 "bindings": {
445 "cmd-1": ["vim::PushObject", { "around": false }],
446 "cmd-3": ["pane::CloseActiveItem", { "save_intent": "save_all" }],
447 "cmd-2": ["vim::NextWordStart", { "ignore_punctuation": true }],
448 "cmd-4": ["task::Spawn", { "task_name": "a b" }] // should remain as it is
449 }
450 }
451 ]
452 "#,
453 ),
454 )
455 }
456
457 #[test]
458 fn test_replace_setting_name() {
459 assert_migrate_settings(
460 r#"
461 {
462 "show_inline_completions_in_menu": true,
463 "show_inline_completions": true,
464 "inline_completions_disabled_in": ["string"],
465 "inline_completions": { "some" : "value" }
466 }
467 "#,
468 Some(
469 r#"
470 {
471 "show_edit_predictions_in_menu": true,
472 "show_edit_predictions": true,
473 "edit_predictions_disabled_in": ["string"],
474 "edit_predictions": { "some" : "value" }
475 }
476 "#,
477 ),
478 )
479 }
480
481 #[test]
482 fn test_nested_string_replace_for_settings() {
483 assert_migrate_settings(
484 r#"
485 {
486 "features": {
487 "inline_completion_provider": "zed"
488 },
489 }
490 "#,
491 Some(
492 r#"
493 {
494 "features": {
495 "edit_prediction_provider": "zed"
496 },
497 }
498 "#,
499 ),
500 )
501 }
502
503 #[test]
504 fn test_replace_settings_in_languages() {
505 assert_migrate_settings(
506 r#"
507 {
508 "languages": {
509 "Astro": {
510 "show_inline_completions": true
511 }
512 }
513 }
514 "#,
515 Some(
516 r#"
517 {
518 "languages": {
519 "Astro": {
520 "show_edit_predictions": true
521 }
522 }
523 }
524 "#,
525 ),
526 )
527 }
528
529 #[test]
530 fn test_replace_settings_value() {
531 assert_migrate_settings(
532 r#"
533 {
534 "scrollbar": {
535 "diagnostics": true
536 },
537 "chat_panel": {
538 "button": true
539 }
540 }
541 "#,
542 Some(
543 r#"
544 {
545 "scrollbar": {
546 "diagnostics": "all"
547 },
548 "chat_panel": {
549 "button": "always"
550 }
551 }
552 "#,
553 ),
554 )
555 }
556
557 #[test]
558 fn test_replace_settings_name_and_value() {
559 assert_migrate_settings(
560 r#"
561 {
562 "tabs": {
563 "always_show_close_button": true
564 }
565 }
566 "#,
567 Some(
568 r#"
569 {
570 "tabs": {
571 "show_close_button": "always"
572 }
573 }
574 "#,
575 ),
576 )
577 }
578
579 #[test]
580 fn test_replace_bash_with_terminal_in_profiles() {
581 assert_migrate_settings(
582 r#"
583 {
584 "assistant": {
585 "profiles": {
586 "custom": {
587 "name": "Custom",
588 "tools": {
589 "bash": true,
590 "diagnostics": true
591 }
592 }
593 }
594 }
595 }
596 "#,
597 Some(
598 r#"
599 {
600 "agent": {
601 "profiles": {
602 "custom": {
603 "name": "Custom",
604 "tools": {
605 "terminal": true,
606 "diagnostics": true
607 }
608 }
609 }
610 }
611 }
612 "#,
613 ),
614 )
615 }
616
617 #[test]
618 fn test_replace_bash_false_with_terminal_in_profiles() {
619 assert_migrate_settings(
620 r#"
621 {
622 "assistant": {
623 "profiles": {
624 "custom": {
625 "name": "Custom",
626 "tools": {
627 "bash": false,
628 "diagnostics": true
629 }
630 }
631 }
632 }
633 }
634 "#,
635 Some(
636 r#"
637 {
638 "agent": {
639 "profiles": {
640 "custom": {
641 "name": "Custom",
642 "tools": {
643 "terminal": false,
644 "diagnostics": true
645 }
646 }
647 }
648 }
649 }
650 "#,
651 ),
652 )
653 }
654
655 #[test]
656 fn test_no_bash_in_profiles() {
657 assert_migrate_settings(
658 r#"
659 {
660 "assistant": {
661 "profiles": {
662 "custom": {
663 "name": "Custom",
664 "tools": {
665 "diagnostics": true,
666 "find_path": true,
667 "read_file": true
668 }
669 }
670 }
671 }
672 }
673 "#,
674 Some(
675 r#"
676 {
677 "agent": {
678 "profiles": {
679 "custom": {
680 "name": "Custom",
681 "tools": {
682 "diagnostics": true,
683 "find_path": true,
684 "read_file": true
685 }
686 }
687 }
688 }
689 }
690 "#,
691 ),
692 )
693 }
694
695 #[test]
696 fn test_rename_path_search_to_find_path() {
697 assert_migrate_settings(
698 r#"
699 {
700 "assistant": {
701 "profiles": {
702 "default": {
703 "tools": {
704 "path_search": true,
705 "read_file": true
706 }
707 }
708 }
709 }
710 }
711 "#,
712 Some(
713 r#"
714 {
715 "agent": {
716 "profiles": {
717 "default": {
718 "tools": {
719 "find_path": true,
720 "read_file": true
721 }
722 }
723 }
724 }
725 }
726 "#,
727 ),
728 );
729 }
730
731 #[test]
732 fn test_rename_assistant() {
733 assert_migrate_settings(
734 r#"{
735 "assistant": {
736 "foo": "bar"
737 },
738 "edit_predictions": {
739 "enabled_in_assistant": false,
740 }
741 }"#,
742 Some(
743 r#"{
744 "agent": {
745 "foo": "bar"
746 },
747 "edit_predictions": {
748 "enabled_in_text_threads": false,
749 }
750 }"#,
751 ),
752 );
753 }
754
755 #[test]
756 fn test_comment_duplicated_agent() {
757 assert_migrate_settings(
758 r#"{
759 "agent": {
760 "name": "assistant-1",
761 "model": "gpt-4", // weird formatting
762 "utf8": "привіт"
763 },
764 "something": "else",
765 "agent": {
766 "name": "assistant-2",
767 "model": "gemini-pro"
768 }
769 }
770 "#,
771 Some(
772 r#"{
773 /* Duplicated key auto-commented: "agent": {
774 "name": "assistant-1",
775 "model": "gpt-4", // weird formatting
776 "utf8": "привіт"
777 }, */
778 "something": "else",
779 "agent": {
780 "name": "assistant-2",
781 "model": "gemini-pro"
782 }
783 }
784 "#,
785 ),
786 );
787 }
788}