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// - [ ] Implement actually updating the state from the `projections.json` file
18// - [ ] Make this work with excerpts in multibuffers
19
20use crate::Vim;
21use editor::Editor;
22use gpui::Context;
23use gpui::Window;
24use gpui::actions;
25use project::ProjectItem;
26use project::ProjectPath;
27use regex::Regex;
28use util::rel_path::RelPath;
29
30#[derive(Debug)]
31struct Projection {
32 source: Regex,
33 target: String,
34}
35
36impl Projection {
37 fn new(source: &str, target: &str) -> Self {
38 // Replace the `*` character in the source string, if such a character
39 // is present, with a capture group, so we can then replace that value
40 // when determining the target.
41 // TODO!: Support for multiple `*` characters?
42 // TODO!: Validation that the number of `{}` in the target matches the
43 // number of `*` on the source.
44 // TODO!: Avoid `unwrap` here by updating `new` to return
45 // `Result<Self>`/`Option<Self>`.
46 let source = Regex::new(&source.replace("*", "(.*)")).unwrap();
47 let target = String::from(target);
48
49 Self { source, target }
50 }
51
52 /// Determines whether the provided path matches this projection's source.
53 /// TODO!: We'll likely want to update this to use `ProjectPath` instead of
54 /// `&str`.
55 fn matches(&self, path: &str) -> bool {
56 self.source.is_match(path)
57 }
58
59 /// Returns the alternate path for the provided path.
60 /// TODO!: Update to work with more than one capture group?
61 fn alternate(&self, path: &str) -> String {
62 // Determine the captures for the path.
63 if let Some(capture) = self.source.captures_iter(path).next() {
64 let (_, [name]) = capture.extract();
65 self.target.replace("{}", name)
66 } else {
67 // TODO!: Can't find capture. Is this a regex without capture group?
68 String::new()
69 }
70 }
71}
72
73actions!(
74 vim,
75 [
76 /// Opens a projection of the current file.
77 OpenProjection,
78 ]
79);
80
81pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
82 Vim::action(editor, cx, Vim::open_projection);
83}
84
85impl Vim {
86 pub fn open_projection(
87 &mut self,
88 _: &OpenProjection,
89 window: &mut Window,
90 cx: &mut Context<Self>,
91 ) {
92 // Implementation for opening a projection
93 dbg!("[vim] attempting to open projection...");
94 self.update_editor(cx, |_vim, editor, cx| {
95 let project_path = editor
96 .buffer()
97 .read(cx)
98 .as_singleton()
99 .and_then(|buffer| buffer.read(cx).project_path(cx));
100
101 // User is editing an empty buffer, can't even find a projection.
102 if project_path.is_none() {
103 return;
104 }
105
106 if let Some(project_path) = project_path
107 && let Some(workspace) = editor.workspace()
108 {
109 dbg!(&project_path);
110 if project_path.path.as_unix_str()
111 == "lib/phx_new_web/controllers/page_controller.ex"
112 {
113 dbg!("[vim] opening projection...");
114 workspace
115 .update(cx, |workspace, cx| {
116 let worktree_id = project_path.worktree_id;
117 let mut project_path = ProjectPath::root_path(worktree_id);
118 project_path.path = RelPath::unix(
119 "test/phx_new_web/controllers/page_controller_test.exs",
120 )
121 .unwrap()
122 .into_arc();
123 dbg!(&project_path);
124
125 workspace.open_path(project_path, None, true, window, cx)
126 })
127 .detach();
128 }
129 }
130 });
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::Projection;
137 use gpui::TestAppContext;
138
139 #[gpui::test]
140 async fn test_matches(_cx: &mut TestAppContext) {
141 let source = "lib/app/*.ex";
142 let target = "test/app/{}_test.exs";
143 let projection = Projection::new(source, target);
144
145 let path = "lib/app/module.ex";
146 assert_eq!(projection.matches(path), true);
147
148 let path = "test/app/module_test.exs";
149 assert_eq!(projection.matches(path), false);
150 }
151
152 #[gpui::test]
153 async fn test_alternate(_cx: &mut TestAppContext) {
154 let source = "lib/app/*.ex";
155 let target = "test/app/{}_test.exs";
156 let projection = Projection::new(source, target);
157
158 let path = "lib/app/module.ex";
159 assert_eq!(projection.alternate(path), "test/app/module_test.exs");
160 }
161}