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