Skip to content

Commit

Permalink
Merge pull request #20 from yassinebridi/code-strucrutal-search
Browse files Browse the repository at this point in the history
Code structural search
  • Loading branch information
yassinebridi authored Jul 13, 2024
2 parents 93b4d70 + 660cda1 commit 61c301a
Show file tree
Hide file tree
Showing 14 changed files with 552 additions and 200 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
[package]
name = "serpl"
version = "0.1.34"
version = "0.2.0"
edition = "2021"
description = "A simple terminal UI for search and replace, ala VS Code"
repository = "https://github.com/yassinebridi/serpl"
authors = ["Yassine Bridi <ybridi@gmail.com>"]
build = "build.rs"
license = "MIT"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[features]
ast_grep = []

[dependencies]
better-panic = "0.3.0"
clap = { version = "4.5.7", features = [
Expand Down
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`serpl` is a terminal user interface (TUI) application that allows users to search and replace keywords in an entire folder, similar to the functionality available in VS Code.

https://github.com/yassinebridi/serpl/assets/18403595/c63627da-7984-4e5f-b1e2-ff14a5d44453
https://github.com/yassinebridi/serpl/assets/18403595/348506704-73336074-bfaf-4a9a-849c-bd4aa4e24afc

## Table of Contents

Expand All @@ -29,8 +29,8 @@ https://github.com/yassinebridi/serpl/assets/18403595/c63627da-7984-4e5f-b1e2-ff

## Features

- Search for keywords across an entire project folder.
- Replace keywords with options for preserving case.
- Search for keywords across an entire project folder, with options for case sensitivity, AST Grep and more.
- Replace keywords with options for preserving case, AST Grep and more.
- Interactive preview of search results.
- Keyboard navigation for efficient workflow.
- Configurable key bindings and search modes.
Expand All @@ -39,20 +39,20 @@ https://github.com/yassinebridi/serpl/assets/18403595/c63627da-7984-4e5f-b1e2-ff

### Prerequisites

- [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) installed on your system.
- [ripgrep](https://github.com/BurntSushi/ripgrep?tab=readme-ov-file#installation) installed on your system.
- (Optional) [ast-grep](https://ast-grep.github.io) installed on your system, if you want to use the AST Grep functionality.

### Steps

1. Install the application using Cargo:
```bash
cargo install serpl
```
2. Update the application using Cargo:
```bash
cargo install serpl
```
3. Run the application:
- If you want to install the application with the AST Grep functionality, you can use the following command:
```bash
cargo install serpl --features ast-grep
```
2. Run the application:
```bash
serpl
```
Expand Down Expand Up @@ -214,15 +214,15 @@ You can customize the key bindings by modifying the configuration file in the fo
### Search Input

- Input field for entering search keywords.
- Toggle search modes (Simple, Match Case, Whole Word, Regex).
- Toggle search modes (Simple, Match Case, Whole Word, Regex, AST Grep).

> [!TIP]
> If current directory is considerebly large, you have to click `Enter` to start the search.

### Replace Input

- Input field for entering replacement text.
- Toggle replace modes (Simple, Preserve Case).
- Toggle replace modes (Simple, Preserve Case, AST Grep).

### Search Results Pane

Expand All @@ -232,10 +232,9 @@ You can customize the key bindings by modifying the configuration file in the fo

### Preview Pane

- Display of the selected file with highlighted search results.
- Display of the selected file with highlighted search results, and context.
- Navigation to view different matches within the file.
- Option to delete individual lines containing matches.


## Neovim Integration using toggleterm

Expand Down Expand Up @@ -313,6 +312,8 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
## Acknowledgements
- This project was inspired by the [VS Code](https://code.visualstudio.com/) search and replace functionality.
- This project is built using the awesome [ratatui.rs](https://ratatui.rs) library, and build on top of their [Component Template](https://ratatui.rs/templates/component).
- Thanks to the [ripgrep](https://github.com/BurntSushi/ripgrep) project for providing the search functionality.
- Thanks to the [ast-grep](https://ast-grep.github.io) project for providing the AST Grep functionality.
## Similar Projects
- [repgrep](https://github.com/acheronfail/repgrep): An interactive replacer for ripgrep that makes it easy to find and replace across files on the command line.
56 changes: 56 additions & 0 deletions src/astgrep.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct AstGrepOutput {
pub text: String,
pub range: Range,
pub file: String,
pub lines: String,
pub replacement: Option<String>,
#[serde(rename = "replacementOffsets")]
pub replacement_offsets: Option<ReplacementOffsets>,
pub language: String,
#[serde(rename = "metaVariables")]
pub meta_variables: Option<MetaVariables>,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Range {
#[serde(rename = "byteOffset")]
pub byte_offset: ByteOffset,
pub start: Position,
pub end: Position,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ByteOffset {
pub start: usize,
pub end: usize,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Position {
pub line: usize,
pub column: usize,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct ReplacementOffsets {
pub start: usize,
pub end: usize,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct MetaVariables {
pub single: HashMap<String, MetaVariable>,
pub multi: HashMap<String, Vec<MetaVariable>>,
pub transformed: HashMap<String, String>,
}

#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct MetaVariable {
pub text: String,
pub range: Range,
}
147 changes: 113 additions & 34 deletions src/components/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::{
time::Duration,
};

use color_eyre::eyre::Result;
use color_eyre::{eyre::Result, owo_colors::OwoColorize};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{prelude::*, symbols::scrollbar, widgets::*};
use regex::Regex;
Expand Down Expand Up @@ -99,27 +99,104 @@ impl Preview {
}
}

fn format_match_line<'a>(&self, line: &'a str, submatches: &[SubMatch], replace_text: &'a str) -> Vec<Span<'a>> {
let mut spans = vec![];
let mut last_end = 0;
fn format_match_lines<'a>(
&self,
full_match: &'a str,
submatches: &[SubMatch],
replace_text: &'a String,
replacement: &'a Option<String>,
is_ast_grep: bool,
) -> Vec<Line<'a>> {
let mut lines = Vec::new();
let match_lines: Vec<&str> = full_match.lines().collect();
let replacement_lines: Vec<&str> = replacement.as_ref().map(|r| r.lines().collect()).unwrap_or_default();

for (i, line) in match_lines.iter().enumerate() {
let line_number = submatches[0].line_start + i;
let mut spans = Vec::new();
let mut last_end = 0;

for submatch in submatches {
if line_number >= submatch.line_start && line_number <= submatch.line_end {
let start = if line_number == submatch.line_start { submatch.start } else { 0 };
let end = if line_number == submatch.line_end { submatch.end } else { line.len() };

if start > last_end {
spans.push(Span::raw(&line[last_end..start]));
}

let matched_text = &line[start..end];
if is_ast_grep {
let replacement_line = replacement_lines.get(i).unwrap_or(&"");
if replace_text.is_empty() {
spans.push(Span::styled(matched_text, Style::default().bg(Color::Blue)));
} else {
let (common_prefix, common_suffix) = Self::find_common_parts(matched_text, replacement_line);

spans.push(Span::raw(common_prefix));

let search_diff = &matched_text[common_prefix.len()..matched_text.len() - common_suffix.len()];
if !search_diff.trim().is_empty() {
spans.push(Span::styled(
search_diff,
Style::default().fg(Color::White).bg(Color::LightRed).add_modifier(Modifier::CROSSED_OUT),
));
}

let replace_diff = &replacement_line[common_prefix.len()..replacement_line.len() - common_suffix.len()];
if !replace_diff.trim().is_empty() {
spans.push(Span::styled(replace_diff, Style::default().fg(Color::White).bg(Color::Green)));
}

spans.push(Span::raw(common_suffix));
}
} else if replace_text.is_empty() {
spans.push(Span::styled(matched_text, Style::default().bg(Color::Blue)));
} else {
spans.push(Span::styled(
matched_text,
Style::default().fg(Color::LightRed).add_modifier(Modifier::CROSSED_OUT),
));
spans.push(Span::styled(replace_text, Style::default().fg(Color::White).bg(Color::Green)));
}

last_end = end;
}
}

for submatch in submatches {
if submatch.start > last_end {
spans.push(Span::raw(&line[last_end..submatch.start]));
if last_end < line.len() {
spans.push(Span::raw(&line[last_end..]));
}

let matched_text = &line[submatch.start..submatch.end];
spans.push(Span::styled(matched_text, Style::default().bg(Color::LightRed).add_modifier(Modifier::CROSSED_OUT)));
spans.push(Span::styled(replace_text, Style::default().fg(Color::White).bg(Color::Green)));
lines.push(Line::from(spans));
}

lines
}

last_end = submatch.end;
fn find_common_parts<'a>(s1: &'a str, s2: &'a str) -> (&'a str, &'a str) {
let mut prefix_len = 0;
for (c1, c2) in s1.chars().zip(s2.chars()) {
if c1 == c2 {
prefix_len += 1;
} else {
break;
}
}

if last_end < line.len() {
spans.push(Span::raw(&line[last_end..]));
let mut suffix_len = 0;
for (c1, c2) in s1.chars().rev().zip(s2.chars().rev()) {
if c1 == c2 && suffix_len < s1.len() - prefix_len && suffix_len < s2.len() - prefix_len {
suffix_len += 1;
} else {
break;
}
}

spans
let common_prefix = &s1[..prefix_len];
let common_suffix = &s1[s1.len() - suffix_len..];

(common_prefix, common_suffix)
}
}

Expand Down Expand Up @@ -190,22 +267,13 @@ impl Component for Preview {
let mut lines = vec![];
self.non_divider_lines.clear();

lines.push(Line::from("-".repeat(area.width as usize)).fg(Color::DarkGray));

for (match_index, result) in state.selected_result.matches.iter().enumerate() {
let line_number = result.line_number;
let start_index = lines.len();
let is_selected = self.lines_state.selected().map(|s| s >= start_index && s < start_index + 6).unwrap_or(false);

if is_selected {
if let Some(last_line) = lines.last_mut() {
*last_line = Line::from("-".repeat(area.width as usize)).fg(Color::Yellow);
}
}
let is_selected = self.lines_state.selected().map(|s| s >= start_index).unwrap_or(false);

for (i, line) in result.context_before.iter().enumerate() {
let line_style =
if is_selected { Style::default().fg(Color::White) } else { Style::default().fg(Color::DarkGray) };
let line_style = Style::default().fg(Color::DarkGray);
let context_line_number = line_number.saturating_sub(result.context_before.len() - i);
let spans = vec![
Span::styled(format!("{:4} ", context_line_number), Style::default().fg(Color::Blue)),
Expand All @@ -214,18 +282,29 @@ impl Component for Preview {
lines.push(Line::from(spans));
}

let match_line = result.lines.as_ref().unwrap().text.as_str();
let formatted_line = self.format_match_line(match_line, &result.submatches, &state.replace_text.text);
let mut spans = vec![Span::styled(format!("{:4} ", line_number), Style::default().fg(Color::Blue))];
spans.extend(formatted_line);
self.non_divider_lines.push(lines.len());
lines.push(Line::from(spans));
#[cfg(feature = "ast_grep")]
let is_ast_grep = matches!(state.search_text.kind, SearchTextKind::AstGrep);
#[cfg(not(feature = "ast_grep"))]
let is_ast_grep = false;

let formatted_lines = self.format_match_lines(
&result.lines.as_ref().unwrap().text,
&result.submatches,
&state.replace_text.text,
&result.replacement,
is_ast_grep,
);
for (i, formatted_line) in formatted_lines.clone().into_iter().enumerate() {
let mut spans = vec![Span::styled(format!("{:4} ", line_number + i), Style::default().fg(Color::LightGreen))];
spans.extend(formatted_line.spans);
self.non_divider_lines.push(lines.len());
lines.push(Line::from(spans));
}

for (i, line) in result.context_after.iter().enumerate() {
let line_style =
if is_selected { Style::default().fg(Color::White) } else { Style::default().fg(Color::DarkGray) };
let line_style = Style::default().fg(Color::DarkGray);
let spans = vec![
Span::styled(format!("{:4} ", line_number + i + 1), Style::default().fg(Color::Blue)),
Span::styled(format!("{:4} ", line_number + formatted_lines.len() + i), Style::default().fg(Color::Blue)),
Span::styled(line, line_style),
];
lines.push(Line::from(spans));
Expand Down
Loading

0 comments on commit 61c301a

Please sign in to comment.