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