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 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}