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