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