projections.rs

  1// Projections allow users to associate files within a project as projections of
  2// one another. Inspired by https://github.com/tpope/vim-projectionist .
  3//
  4// Take, for example, a newly generated Phoenix project. Among other files, one
  5// can find the page controller module and its corresponding test file in:
  6//
  7// - `lib/app_web/controllers/page_controller.ex`
  8// - `lib/app_web/controllers/page_controller_test.exs`
  9//
 10// From the point of view of the controller module, one can say that the test
 11// file is a projection of the controller module, and vice versa.
 12//
 13// TODO!:
 14// - [ ] Implement `:a` to open alternate file
 15// - [ ] Implement `:as` to open alternate file in split
 16// - [ ] Implement `:av` to open alternate file in vertical split
 17// - [X] Implement actually updating the state from the `projections.json` file
 18// - [ ] Make this work with excerpts in multibuffers
 19
 20use crate::Vim;
 21use anyhow::Result;
 22use editor::Editor;
 23use gpui::Action;
 24use gpui::Context;
 25use gpui::Window;
 26use gpui::actions;
 27use project::Fs;
 28use project::ProjectItem;
 29use project::ProjectPath;
 30use regex::Regex;
 31use schemars::JsonSchema;
 32use serde::Deserialize;
 33use settings::parse_json_with_comments;
 34use std::collections::HashMap;
 35use std::path::Path;
 36use std::sync::Arc;
 37use util::rel_path::RelPath;
 38use workspace::SplitDirection;
 39
 40#[derive(Debug)]
 41struct Projection {
 42    source: Regex,
 43    target: String,
 44}
 45
 46#[derive(Deserialize, Debug)]
 47struct ProjectionValue {
 48    alternate: String,
 49}
 50
 51type ProjectionsConfig = HashMap<String, ProjectionValue>;
 52
 53impl Projection {
 54    fn new(source: &str, target: &str) -> Self {
 55        // Replace the `*` character in the source string, if such a character
 56        // is present, with a capture group, so we can then replace that value
 57        // when determining the target.
 58        // TODO!: Support for multiple `*` characters?
 59        // TODO!: Validation that the number of `{}` in the target matches the
 60        // number of `*` on the source.
 61        // TODO!: Avoid `unwrap` here by updating `new` to return
 62        // `Result<Self>`/`Option<Self>`.
 63        let source = Regex::new(&source.replace("*", "(.*)")).unwrap();
 64        let target = String::from(target);
 65
 66        Self { source, target }
 67    }
 68
 69    /// Determines whether the provided path matches this projection's source.
 70    fn matches(&self, path: &str) -> bool {
 71        self.source.is_match(path)
 72    }
 73
 74    /// Returns the alternate path for the provided path.
 75    /// TODO!: Update to work with more than one capture group?
 76    fn alternate(&self, path: &str) -> String {
 77        // Determine the captures for the path.
 78        if let Some(capture) = self.source.captures_iter(path).next() {
 79            let (_, [name]) = capture.extract();
 80            self.target.replace("{}", name)
 81        } else {
 82            // TODO!: Can't find capture. Is this a regex without capture group?
 83            String::new()
 84        }
 85    }
 86}
 87
 88#[derive(Debug, Clone, JsonSchema, PartialEq, Deserialize, Action)]
 89#[action(namespace = vim)]
 90pub(crate) struct OpenAlternate {
 91    pub(crate) split_direction: Option<SplitDirection>,
 92}
 93
 94actions!(vim, [OpenAlternateVerticalSplit,]);
 95
 96pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
 97    Vim::action(editor, cx, Vim::open_alternate);
 98    Vim::action(editor, cx, Vim::open_alternate_vertical_split);
 99}
100
101async fn load_projections(root_path: &Path, fs: Arc<dyn Fs>) -> Result<ProjectionsConfig> {
102    let projections_path = root_path.join(".zed").join("projections.json");
103
104    let content = fs.load(&projections_path).await?;
105    let config = parse_json_with_comments::<ProjectionsConfig>(&content)?;
106
107    Ok(config)
108}
109
110impl Vim {
111    pub fn open_alternate(
112        &mut self,
113        action: &OpenAlternate,
114        window: &mut Window,
115        cx: &mut Context<Self>,
116    ) {
117        let split_direction = action.split_direction.clone();
118        self.update_editor(cx, move |_vim, editor, cx| {
119            let current_file_path = editor
120                .buffer()
121                .read(cx)
122                .as_singleton()
123                .and_then(|buffer| buffer.read(cx).project_path(cx));
124
125            // User is editing an empty buffer, can't find a projection.
126            let Some(current_file_path) = current_file_path else {
127                return;
128            };
129
130            let Some(workspace) = editor.workspace() else {
131                return;
132            };
133
134            let Some(project) = editor.project() else {
135                return;
136            };
137
138            // Extract data we need before going async
139            let worktree_id = current_file_path.worktree_id;
140            let current_path = current_file_path.path.clone();
141            let fs = project.read(cx).fs().clone();
142
143            // Get the worktree to extract its root path
144            let worktree = project.read(cx).worktree_for_id(worktree_id, cx);
145
146            let Some(worktree) = worktree else {
147                return;
148            };
149
150            let root_path = worktree.read(cx).abs_path();
151
152            workspace.update(cx, |_workspace, cx| {
153                cx.spawn_in(window, async move |workspace, cx| {
154                    // Load the projections configuration
155                    let config = match load_projections(&root_path, fs).await {
156                        Ok(config) => {
157                            log::info!("Loaded projections config: {:?}", config);
158                            config
159                        }
160                        Err(err) => {
161                            log::warn!("Failed to load projections: {:?}", err);
162                            return;
163                        }
164                    };
165
166                    // Convert config to Projection instances and find a match
167                    let current_path_str = current_path.as_unix_str();
168                    log::info!("Looking for projection for path: {}", current_path_str);
169                    let mut alternate_path: Option<String> = None;
170
171                    for (source_pattern, projection_value) in config.iter() {
172                        log::debug!(
173                            "Trying pattern '{}' -> '{}'",
174                            source_pattern,
175                            projection_value.alternate
176                        );
177                        let projection =
178                            Projection::new(source_pattern, &projection_value.alternate);
179
180                        if projection.matches(current_path_str) {
181                            let alt = projection.alternate(current_path_str);
182                            log::info!("Found match! Alternate path: {}", alt);
183                            alternate_path = Some(alt);
184                            break;
185                        }
186                    }
187
188                    // If we found an alternate, open it
189                    if let Some(alternate_path) = alternate_path {
190                        let alternate_rel_path = match RelPath::unix(&alternate_path) {
191                            Ok(path) => path,
192                            Err(_) => return,
193                        };
194
195                        let alternate_project_path = ProjectPath {
196                            worktree_id,
197                            path: alternate_rel_path.into_arc(),
198                        };
199
200                        let result =
201                            workspace.update_in(
202                                cx,
203                                |workspace, window, cx| match split_direction {
204                                    None => workspace.open_path(
205                                        alternate_project_path,
206                                        None,
207                                        true,
208                                        window,
209                                        cx,
210                                    ),
211                                    // TODO!: Update to actually support left/down/up/right.
212                                    Some(_split_direction) => {
213                                        workspace.split_path(alternate_project_path, window, cx)
214                                    }
215                                },
216                            );
217
218                        match result {
219                            Ok(task) => {
220                                task.detach();
221                            }
222                            Err(err) => {
223                                log::error!("Failed to open alternate file: {:?}", err);
224                            }
225                        }
226                    } else {
227                        log::info!("No alternate projection found for: {}", current_path_str);
228                    }
229                })
230                .detach();
231            });
232        });
233    }
234
235    pub fn open_alternate_vertical_split(
236        &mut self,
237        _action: &OpenAlternateVerticalSplit,
238        window: &mut Window,
239        cx: &mut Context<Self>,
240    ) {
241        self.open_alternate(
242            &OpenAlternate {
243                split_direction: Some(SplitDirection::Right),
244            },
245            window,
246            cx,
247        );
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::Projection;
254    use super::load_projections;
255    use gpui::TestAppContext;
256    use project::FakeFs;
257    use project::Project;
258    use serde_json::json;
259    use settings::SettingsStore;
260    use util::path;
261
262    #[gpui::test]
263    async fn test_matches(_cx: &mut TestAppContext) {
264        let source = "lib/app/*.ex";
265        let target = "test/app/{}_test.exs";
266        let projection = Projection::new(source, target);
267
268        let path = "lib/app/module.ex";
269        assert_eq!(projection.matches(path), true);
270
271        let path = "test/app/module_test.exs";
272        assert_eq!(projection.matches(path), false);
273    }
274
275    #[gpui::test]
276    async fn test_alternate(_cx: &mut TestAppContext) {
277        let source = "lib/app/*.ex";
278        let target = "test/app/{}_test.exs";
279        let projection = Projection::new(source, target);
280
281        let path = "lib/app/module.ex";
282        assert_eq!(projection.alternate(path), "test/app/module_test.exs");
283    }
284
285    #[gpui::test]
286    async fn test_load_projections(cx: &mut TestAppContext) {
287        cx.update(|cx| {
288            let settings_store = SettingsStore::test(cx);
289            cx.set_global(settings_store);
290        });
291
292        let fs = FakeFs::new(cx.executor());
293        fs.insert_tree(
294            path!("/dir"),
295            json!({
296                ".zed": {
297                    "projections.json": r#"{
298                        "src/main/java/*.java": {"alternate": "src/test/java/{}.java"},
299                        "src/test/java/*.java": {"alternate": "src/main/java/{}.java"}
300                    }"#
301                }
302            }),
303        )
304        .await;
305
306        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
307        let worktree = project.read_with(cx, |project, _cx| project.worktrees(_cx).next().unwrap());
308
309        let root_path = worktree.read_with(cx, |wt, _| wt.abs_path());
310        let config = load_projections(&root_path, fs).await.unwrap();
311
312        assert_eq!(config.len(), 2);
313        assert_eq!(
314            config.get("src/main/java/*.java").unwrap().alternate,
315            "src/test/java/{}.java"
316        );
317        assert_eq!(
318            config.get("src/test/java/*.java").unwrap().alternate,
319            "src/main/java/{}.java"
320        );
321    }
322}