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