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