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, 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    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}