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}