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