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