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// - [ ] 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}