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}