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