Skip to contents

Overview

The central object in seekr is the seekr_match vector. Technically, it is implemented as a vctrs record-style vector: a vector object made of several same-length fields, where the internal field structure supports the object but is not the main user-facing abstraction.

This article describes the main design choices behind seekr: how search results are represented, how they can be inspected and refined, and how replacement can remain explicit and safe.

Design requirements

The design started from the workflow seekr was meant to support. The goal was to let users:

  • read files once during the search step, and read them again only if replacements are applied;
  • print matches and planned replacements with surrounding context;
  • preview replacements before files are written;
  • prepare replacements either during the search step or after matches have been inspected;
  • update the replacements;
  • filter to keep a subset of the matches;
  • replace only the remaining matches, safely.

More generally, the result needed to expose the information captured for each match, so users could use it for their own workflows. This includes understanding what was matched and where, filtering matches more precisely, summarizing search results, or preparing and updating replacements using the context and other captured fields.

The seekr_match vector is the object that came out of these requirements. It allows workflows like this:

x <- seekr("old_name", "new_name")

summary(x)
print(x, context = 2)
x <- filter_match(x, path != "legacy.R")
field(x, "replacement") <- toupper(field(x, "replacement"))

replace_files(x)

Why a match vector?

Search-and-replace is often treated as a single operation: find all occurrences of a pattern and replace them immediately.

seekr splits this into separate steps. A user can search first, inspect the matches, filter out false positives, update replacements, and only then write changes to disk.

That workflow requires the search result to remain usable as an object after the search. It must be possible to subset it, combine it, print it, summarize it, convert it to a tibble, update planned replacements, and eventually pass it to replace_files().

This is why seekr represents search results as a vector of matches.

Each element of the vector represents one match. The vector abstraction makes the common operations simple: subsetting keeps matches, concatenating combines matches, and filtering removes matches. The object can behave like a vector while still storing the metadata needed for inspection and replacement.

Designs considered

Before settling on the seekr_match vector, a few alternative designs were considered. The two most important were returning a data frame and returning a file-centric object.

Why not a data frame?

A data frame was the most obvious alternative. It is familiar to R users, easy to inspect, and useful for tabular workflows.

seekr supports this workflow through as_tibble() and as_match(), so users can move to and from a tibble when that representation is convenient. See Tabular workflows for examples.

But a data frame is not the right core representation for seekr.

Most fields in a match should not be edited directly by the user. Fields such as path, start, end, match, line, encoding, and hash describe what was found. They are part of the recorded search result. In normal use, the only field users are expected to modify is replacement.

In a data frame, all columns feel equally editable. A seekr_match vector makes the intended abstraction clearer: the object is a collection of matches, not a regular table of analysis variables.

Printing is also not naturally tabular. The useful output for a search result groups matches by file, shows context, highlights the matched text, and previews planned replacements.

Finally, a seekr_match vector can preserve invariants that would be easier to break in a plain data frame. It can behave like a vector while still keeping the fields required for safe replacement together.

Why not a file-centric object?

Another possible design was a file-centric object: one object per file, with the matches nested inside each file.

This would mirror the structure of the input files, and it has some attractive properties. Search happens in files, so grouping by file is natural. Information shared by all matches in a file, such as the path, encoding, or full text, could also live once at the file level.

But having a list of file objects as the result of a search would make the user-facing workflow harder to understand and to manipulate. Filtering individual matches would require working inside a nested structure. Updating replacements would mean modifying matches inside file objects. Combining results from several searches would raise questions about how file-level objects should be merged.

It would also make common match-level questions more complex. For example, keeping only matches whose surrounding context contains a specific word should be a simple filtering operation. In a file-centric object, context would either need to live inside nested matches, or be recomputed from file-level text. Both options make the object harder to reason about.

seekr treats each match as the unit of work. Files still matter, and matches still know which file they came from, but the object itself is match-centric. If a search returns 50 matches across 20 files, the result is a vector of 50 matches. This makes common operations simpler: subset matches, concatenate matches, filter by matched text or context, update replacements, and replace the selected matches.

Replacing inspected matches safely

The match vector also matters when files are modified.

Many search-and-replace tools show matches first, then run a second search when replacement is applied. seekr does not work that way. replace_files() starts from the current seekr_match vector and only considers the matches still present in that object.

If the user filters out a match, that match is not replaced. If the user changes the replacement field for one match, that match gets its own replacement. The replacement step follows the object the user inspected and edited.

To make this safe, seekr records a hash of the searched text when a seekr_match vector is created. When replacements are applied, the hash of the current text is computed again: replace_files() re-reads the file before writing, and replace_text() checks the text supplied by the user.

If the hash has changed, seekr refuses to apply the replacements. This is strict by design: the goal is not to guess whether recorded positions are still probably safe, but to ensure that replacements are applied only to the exact text that was searched and inspected.

The consequence is simple: if the text changed, search again and create a new seekr_match vector. This also means that seekr is not designed for sources that are continuously changing, such as log files receiving new lines every few seconds. This is an acceptable trade-off for a workflow focused on inspection, control, and safe replacement.

Encoding

Encoding is another place where seekr chooses an explicit design.

When files are searched, the encoding used to read each file is recorded in the seekr_match vector. When replacements are applied with replace_files(), modified files are written as UTF-8.

By default, replace_files() refuses to silently rewrite a non-UTF-8 file as UTF-8. The user has to make that choice explicitly.

This is a trade-off. seekr does not try to be a universal encoding-preserving editor. Instead, it makes encoding changes visible and deliberate.

If the user needs full control over reading and writing text, they can use the text-level workflow described in Working with text.