Skip to content

【译】 第六章:搜索

May 15, 2022 | 12:51 AM

本文翻译自:Hecto, Chapter 6: Search – Philipp Flenker – Engineering Manager ,封面图也来自于此。

我们完成了文本编辑器 — 能打开、编辑和保存文件。接下来的两个功能给它增添了更多的能力。我们会实现一个小小的搜索功能。

为此,我们重用 prompt() 。当用户输入查询请求并按下回车时,我们会遍历文件中的所有行,如果某行包含查询字符串,会移动光标到匹配的位置。为了达到这个目标,我们需要一个在单行搜索并返回匹配位置的方法。现在让我们开始实现。

到 GitHub 查看这一步。

// 译者注:editor.rs 中 135-142 代码如下
Key::Ctrl('f') => {
    if let Some(query) = self.prompt("Search: ").unwrap_or(None) {
        if let Some(position) = self.document.find(&query[..]) {
            self.cursor_position = position;
        } else {
            self.status_message = StatusMessage::from(format!("Not found: {}.", query));
        }
    }
}

我们先从 Raw 开始,过一遍这次改动。我们添加了一个返回 Option 的函数。Option 要么包含匹配单词的 x 坐标,要么是 None。用 String 中的 find 方法检错匹配单词的字节索引。记住这可能跟字形索引不太一样!为了把字节索引转换为字形索引,我们使用了一个稍微有点绕的循环。让我们解释一下。grapheme_indices() 为字符串中每一个字形返回一对 (byte_index, grapheme) 的迭代器。enumerate() 枚举这个迭代器,所以,它会给我们迭代器元素的索引。我们利用这一特点,遍历迭代器,直到我们到达与我们匹配单词有相同索引的字形,并返回相应的字形索引。

Document 中,如果有的话,我们尝试返回全文中匹配单词的 Position。我们通过遍历所有行并在每一行执行 match 来实现。然后,把现在行的索引和匹配单词的索引作为文档中匹配单词的 Position 返回。

Editor 中,我们使用了一个和之前 prompt 那里相似的逻辑。从用户那获取搜索查询的单词,然后传递给 find。如果找到了匹配项,移动光标。如果没有,展示信息给用户。最后但不是不重要的是,我们也修改了展示给用户的初始欢迎信息,让他们知道怎样使用新搜索功能。

增量搜索

现在,让我们把搜索功能变得高级一些。想支持增量搜索,这意味着在用户输入查询单词时,每个按键按下后文档都会被搜索一次。为实现这个功能,我们打算让 prompt 接收一个回调函数作为参数。会在每次按键按下后调用该函数,并把由用户输入的查询单词和最后一个按下的键传过去。我会在一会解释这个概念。

到 GitHub 查看这一步。

这里我们使用了一个叫闭包的新概念。它引入一些新语法,让代码有点难以阅读。

让我们先讲一下这个概念。我们想要的是传递函数到 prompt 中,并在每次用户修改查询单词时调用该函数。这样用户就能在打字的过程中对部分搜索单词执行搜索。我们怎样才能实现呢?首先,需要修改 prompt 的签名。它现在像这样:

fn prompt<C>(&mut self, prompt: &str, callback: C) -> Result<Option<String>, std::io::Error> where C: Fn(&mut Self, Key, &String) {
     //...
 }

开头的 C 来自概念 trait。在本教程中,我们不会实现任何复杂的 trait。所以在其它地方你不会再看到这样的语法。其实,C 是回调函数的占位符。你可以看到它再次作为参数 callback 的类型。另一个新东西是方法定义后面的 where ,这是我们定义函数 C 类型的地方。回调函数实际上接收三个参数:Editor,被按下的 Key (现在暂时不管它,但是稍后会用到)和一个 &String ,它表示(部分)搜索查询的单词。我们能看到在用户按下按键后这些参数被用来调用 callback

我们怎样定义一个 callback 呢?嗯,我们能看到 saveprompt 的最简单的例子:|_, _, _| {} 。这是接收三个参数的闭包。它忽视所有参数并且什么也没做。

一个更有意义的例子是对『搜索提示』的补充。在这里,闭包需要三个参数(忽略中间的参数)然后真正地做了些什么。因为它被部分搜索单词调用,所以我们能执行搜索并设置每次按键按下后的光标。

或许你熟悉其它程序设计语言的闭包。你可能想问:我们就不能直接在闭包中访问 self 而不是把它传给 prompt 然后再给函数吗?嗯,能也不能。能是因为闭包的主要功能就是能访问定义外的变量,但不能是因为在现在的情况下,它不能运行。原因是 prompt 接收 self 的可变引用,因为它需要设置状态信息,所以它也会传给 callback 一个可变引用,这是不允许的。

译者注:同一时刻,只能拥有一个可变引用, 或者任意多个不可变引用

这就是所有内容了。我们现在有增量搜索了。

退出搜索时重置光标位置

如果用户取消搜索,希望重置光标为原来的位置。为此,我们需要在开始 prompt 之前保存光标位置。然后检查返回值,如果是 None,则用户取消搜索,那就重置光标为原来的位置并滚动到那。

到 GitHub 查看这一步。

// 134 行代码如下:
self.status_message = StatusMessage::from(format!("Not found :{}.", query));

为了复制(clone)原位置,我们派生了 Clone trait。它让我们能通过 clone 复制 Position 结构体所有(字段)值。

前向后向搜索

我们想添加的最后一个功能是让用户用箭头符跳转到下一个或者前一个文档中的匹配项。 前往前面的匹配, 前往下个匹配。

到 GitHub 查看这一步。

// 译者注:134 行代码如下
if let Some(position) = editor.document.find(&query, &editor.cursor_position) {

我们先在 find 方法中接收起始位置,表示希望搜索该位置的下个匹配项(译者注:rowdocument 类似)。对于 row,它表示在调用 find 之前跳过字符串索引 after 前的所有内容。因为字形索引表示的是在不带头部字符串的子字符串中的位置,所以需要对匹配项的字形索引加 after。在 Document 中,现在,在执行搜索时,跳过了头部的 y 行。然后尝试在当前行的 x 位置,查找匹配项。如果没找到,继续在下一行寻找,但是重置 x 为 0,从而确保从下一行的开头开始查找。

我们调整了错误信息,并且现在用光标位置调用 find。还用了之前闭包中没用的 key 参数来匹配用户的输入。如果用户按下 或者 ,在继续搜索更新后的参数前,先向右移动光标。为什么呢?因为如果光标已经在搜索匹配项的位置上了,就会返回给我们现在的位置。所以先向当前位置的右侧移动。如果 find 没有返回匹配项,我们希望光标呆在之前的位置上。所以,用 moved 记录是否移动了光标,如果没找到就移回去。

前向寻找

现在让我们看看前向搜索。

// 译者注:
// 现在的 search 函数如下
fn search(&mut self) {
      let old_position = self.cursor_position.clone();
      let mut direction = SearchDirection::Forward;
      let query = self
          .prompt(
              "Search (ESC to cancel, Arrows to navigate): ",
              |editor, key, query| {
                  let mut moved = false;
                  match key {
                      Key::Right | Key::Down => {
                          direction = SearchDirection::Forward;
                          editor.move_cursor(Key::Right);
                          moved = true
                      }
                      Key::Left | Key::Up=> direction=SearchDirection::Backward,
                      _ => (),
                  }

                  if let Some(position) = editor.
                          document.
                          find(&query, &editor.cursor_position, direction) {
                      editor.cursor_position = position;
                      editor.scroll();
                  } else if moved {
                      editor.move_cursor(Key::Left);
                  }
              },
          )
          .unwrap_or(None);
      if query.is_none() {
          self.cursor_position = old_position;
          self.scroll();
      }
  }

// prompt 函数签名如下:
fn prompt<C>(&mut self, prompt: &str, mut callback: C) -> Result<Option<String>, std::io::Error>
    where
        C: FnMut(&mut Self, Key, &String),

// 译者注:108 行代码如下
pub fn find(&self, query: &str, at: usize, direction: SearchDirection) -> Option<usize> {

到 GitHub 查看这一步。

对我们实现的功能来说,这真的是一个很大的改动。让我们一步一步地解释改动,先说 Row

重命名 afterat,因为我们不光需要查找某些后面的内容,还要查找给定索引前的内容。然后根据待搜索方向计算子字符串的大小:要不是想搜索从头到当前位置的子字符串,要不是从当前位置到本行的末尾。一旦我们拿到了正确的长度,就 collect 迭代器为真正的子字符串。在这里允许整数计算,因为确认了 end 总是大于等于 start,并且总是小于等于当前行的长度。

你能看见一个枚举(enum):SearchDirection。我们待会再说它的概念。现在用它来判断是否需要在给定字符串中查找第一个出现的匹配项(调用 find)还是最后一个匹配项(调用 rfind)。这样确保了我们在正确方向搜索匹配项。

Document 中,因为需要根据向前还是向后搜索文档,所以我们自己创建了迭代器。根据搜索方向确定要迭代的行的范围,然后再执行搜索。跟前面差不多:如果有匹配项就返回当前查到的位置;如果没有,就设置 y 为下一行或者上一行,如果前向搜索设置 x 为 0,如果后向搜索设置为前一行的长度。

editor 中,你能看到新枚举 SearchDirection 的定义。它派生了 Copy 和 Clone — 这使该枚举可以被相互之间传递(它的结构足够小,所以复制它而不是传递它的引用,影响不会很大),和 PartialEq — 确保能比较两个搜索方向是否相等。然后,定义了一个新变量 direction。我们打算在 prompt 闭包中修改它。如果按下 或者 就修改搜索方向为 backward ,如果按下 或者 就修改搜索方向为 forward。然后传递搜索方向给修改过的 find 方法。请注意,如果按下的是其它键,我们还会再修改搜索方向为 forward。如果不这样的话,搜索的行为就会有些怪异。假设光标在 foo bar baz 中的 baz 上,用户输入 b 搜索方向是 backward,如果继续输入 a,查询就变成了 ba ,而光标会跳转回 bar 而不是待在 baz 上,即使 baz 也匹配该查询。我们还删掉了最后的搜索和用户取消搜索情况下的 “Not Found” 信息。早就在闭包中搜索了,所以最后多余的搜索不会给我们带来什么价值。

最后但不是不重要的是,我们需要更新回调函数的签名,让 callback 能修改其访问的变量。这通过修改callbackFnMut 实现。

恭喜,现在我们实现了搜索功能!

结论

由于我们的功能已经完成,就又用这章解释一下 Rust 主题,并浅析了闭包(Closure)和 Trait 话题。我们的编辑器基本完工。下一章中,我们将实现语法高亮和文件类型检测功能,让编辑器更完善。