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