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