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