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