migrator.rs

  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(&current_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}