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