Skip to contents

Introduction

seekr turns search-and-replace across files into an explicit workflow.

This article gives a detailed walkthrough of that workflow. It starts from the basic steps—listing files, filtering files, and finding matches—and then shows how matches can be inspected, filtered, updated, replaced, and restored.

The examples use a temporary copy of the example files shipped with seekr, so they are safe to modify.

List files

The first step is to list candidate files.

list_files() lists the files that exist and that will be filtered before looking for matches. Its main arguments are path, which defines where to look, all, which controls whether hidden files and directories are included, and recurse, which controls whether and how deeply subdirectories are searched.

By default, list_files() searches recursively from the current directory, and ignores hidden files and directories.

When searching inside a Git repository, you can also set use_git = TRUE to restrict file discovery to files Git considers relevant: tracked files and untracked files that are not ignored by Git.

files <- list_files()
files
#> [1] "/tmp/RtmpxG7QUT/seekr-example/extdata/config.yaml" "/tmp/RtmpxG7QUT/seekr-example/extdata/data.json"  
#> [3] "/tmp/RtmpxG7QUT/seekr-example/extdata/iris.csv"    "/tmp/RtmpxG7QUT/seekr-example/extdata/mtcars.csv" 
#> [5] "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R"   "/tmp/RtmpxG7QUT/seekr-example/extdata/script2.R"  
#> [7] "/tmp/RtmpxG7QUT/seekr-example/extdata/server1.log" "/tmp/RtmpxG7QUT/seekr-example/extdata/server2.log"

list_files() is intentionally simple. Its role is to discover candidate files, not to decide which files are relevant for a particular search. That decision is handled later by filter_files().

This separation is deliberate. File discovery can stay broad and predictable, while file filtering remains explicit and inspectable.

Filter files

Once files have been listed, filter_files() excludes the files that should not be searched.

Built-in filters

filter_files() takes a vector of file paths, typically returned by list_files(), and returns the subset of files that should be searched.

It has three main built-in filters:

  • extension, to keep files with selected extensions,
  • path_pattern, to keep files whose path matches a pattern,
  • max_file_size, to keep files below a size limit.

In addition to these built-in filters, filter_files() also uses a list of exclude_functions. These are predicate-like functions that decide whether specific files should be excluded. By default, they are used to exclude files that should usually not be searched, such as non-text files or files handled by the default exclusion rules.

For example, we can keep only R files.

filter_files(files, extension = "R")
#> [1] "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R" "/tmp/RtmpxG7QUT/seekr-example/extdata/script2.R"
#> attr(,"exclusions")
#> # A tibble: 8 × 7
#>   path                                              excluded exclude_by_extension is_git_dir is_dependency_dir is_minified_file is_not_text_mime
#>   <chr>                                             <lgl>    <lgl>                <lgl>      <lgl>             <lgl>            <lgl>           
#> 1 /tmp/RtmpxG7QUT/seekr-example/extdata/config.yaml TRUE     TRUE                 NA         NA                NA               NA              
#> 2 /tmp/RtmpxG7QUT/seekr-example/extdata/data.json   TRUE     TRUE                 NA         NA                NA               NA              
#> 3 /tmp/RtmpxG7QUT/seekr-example/extdata/iris.csv    TRUE     TRUE                 NA         NA                NA               NA              
#> 4 /tmp/RtmpxG7QUT/seekr-example/extdata/mtcars.csv  TRUE     TRUE                 NA         NA                NA               NA              
#> 5 /tmp/RtmpxG7QUT/seekr-example/extdata/script1.R   FALSE    FALSE                FALSE      FALSE             FALSE            FALSE           
#> 6 /tmp/RtmpxG7QUT/seekr-example/extdata/script2.R   FALSE    FALSE                FALSE      FALSE             FALSE            FALSE           
#> 7 /tmp/RtmpxG7QUT/seekr-example/extdata/server1.log TRUE     TRUE                 NA         NA                NA               NA              
#> 8 /tmp/RtmpxG7QUT/seekr-example/extdata/server2.log TRUE     TRUE                 NA         NA                NA               NA

Here we combine the different types of filters to exclude the files we are not interested in.

# Add a dummy png file to illustrate the exclusion of non-text files by default
files <- c(files, "server.png")

filtered <- filter_files(
  files, 
  extension = c("r", "log", "yaml", "png"),
  path_pattern = "script|server",
  max_file_size = 1000L
)

filtered
#> [1] "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R" "/tmp/RtmpxG7QUT/seekr-example/extdata/script2.R"
#> attr(,"exclusions")
#> # A tibble: 9 × 9
#>   path        excluded exclude_by_extension exclude_by_path_patt…¹ exclude_by_file_size is_git_dir is_dependency_dir is_minified_file is_not_text_mime
#>   <chr>       <lgl>    <lgl>                <lgl>                  <lgl>                <lgl>      <lgl>             <lgl>            <lgl>           
#> 1 /tmp/Rtmpx… TRUE     FALSE                TRUE                   NA                   NA         NA                NA               NA              
#> 2 /tmp/Rtmpx… TRUE     TRUE                 NA                     NA                   NA         NA                NA               NA              
#> 3 /tmp/Rtmpx… TRUE     TRUE                 NA                     NA                   NA         NA                NA               NA              
#> 4 /tmp/Rtmpx… TRUE     TRUE                 NA                     NA                   NA         NA                NA               NA              
#> 5 /tmp/Rtmpx… FALSE    FALSE                FALSE                  FALSE                FALSE      FALSE             FALSE            FALSE           
#> 6 /tmp/Rtmpx… FALSE    FALSE                FALSE                  FALSE                FALSE      FALSE             FALSE            FALSE           
#> 7 /tmp/Rtmpx… TRUE     FALSE                FALSE                  TRUE                 NA         NA                NA               NA              
#> 8 /tmp/Rtmpx… TRUE     FALSE                FALSE                  TRUE                 NA         NA                NA               NA              
#> 9 /tmp/Rtmpx… TRUE     FALSE                FALSE                  FALSE                FALSE      FALSE             FALSE            TRUE            
#> # ℹ abbreviated name: ¹​exclude_by_path_pattern

Inspect exclusions

Filtering is inspectable. filter_files() records which files were excluded and why.

In this example, files are first excluded by extension. Then, among the remaining files, paths that do not match path_pattern are excluded. Finally, the two log files are excluded because they are above the size limit set with max_file_size.

Each column in the exclusions table represents one filtering or exclusion step. The last column shows that the dummy "server.png" file was excluded by the default rules because its extension is not associated with text files.

exclusions(filtered)
#> # A tibble: 9 × 9
#>   path        excluded exclude_by_extension exclude_by_path_patt…¹ exclude_by_file_size is_git_dir is_dependency_dir is_minified_file is_not_text_mime
#>   <chr>       <lgl>    <lgl>                <lgl>                  <lgl>                <lgl>      <lgl>             <lgl>            <lgl>           
#> 1 /tmp/Rtmpx… TRUE     FALSE                TRUE                   NA                   NA         NA                NA               NA              
#> 2 /tmp/Rtmpx… TRUE     TRUE                 NA                     NA                   NA         NA                NA               NA              
#> 3 /tmp/Rtmpx… TRUE     TRUE                 NA                     NA                   NA         NA                NA               NA              
#> 4 /tmp/Rtmpx… TRUE     TRUE                 NA                     NA                   NA         NA                NA               NA              
#> 5 /tmp/Rtmpx… FALSE    FALSE                FALSE                  FALSE                FALSE      FALSE             FALSE            FALSE           
#> 6 /tmp/Rtmpx… FALSE    FALSE                FALSE                  FALSE                FALSE      FALSE             FALSE            FALSE           
#> 7 /tmp/Rtmpx… TRUE     FALSE                FALSE                  TRUE                 NA         NA                NA               NA              
#> 8 /tmp/Rtmpx… TRUE     FALSE                FALSE                  TRUE                 NA         NA                NA               NA              
#> 9 /tmp/Rtmpx… TRUE     FALSE                FALSE                  FALSE                FALSE      FALSE             FALSE            TRUE            
#> # ℹ abbreviated name: ¹​exclude_by_path_pattern

This is useful because file filtering can otherwise be hard to audit. Instead of silently excluding files, seekr lets you inspect what was excluded at each step.

Custom exclude functions

In addition to the built-in filters, filter_files() uses a list of exclude functions. These functions receive file paths and return TRUE for files that should be excluded.

seekr provides a default set of exclude functions.

names(exclude_functions)
#> [1] "is_git_dir"        "is_dependency_dir" "is_minified_file"  "is_not_text_mime"

You can add your own named exclude function by modifying a copy of exclude_functions. Custom functions will also appear on their dedicated column in the exclusions() data frame.

my_exclude_functions <- c(
  exclude_functions,
  exclude_script2 = function(path) grepl("script2[.]R$", path)
)

filtered_custom <- filter_files(
  files,
  extension = "R",
  exclude = my_exclude_functions
)

filtered_custom
#> [1] "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R"
#> attr(,"exclusions")
#> # A tibble: 9 × 8
#>   path                                    excluded exclude_by_extension is_git_dir is_dependency_dir is_minified_file is_not_text_mime exclude_script2
#>   <chr>                                   <lgl>    <lgl>                <lgl>      <lgl>             <lgl>            <lgl>            <lgl>          
#> 1 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA             
#> 2 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA             
#> 3 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA             
#> 4 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA             
#> 5 /tmp/RtmpxG7QUT/seekr-example/extdata/… FALSE    FALSE                FALSE      FALSE             FALSE            FALSE            FALSE          
#> 6 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     FALSE                FALSE      FALSE             FALSE            FALSE            TRUE           
#> 7 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA             
#> 8 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA             
#> 9 /tmp/RtmpxG7QUT/seekr-example/extdata/… TRUE     TRUE                 NA         NA                NA               NA               NA

The order of exclude functions matters. They are evaluated in the order in which they appear in exclude, so changing that order can change which exclusion reason is recorded first. It can also matter for performance: computationally expensive functions are usually better placed near the end.

If no exclude functions are used, the exclusions table simply contains the file paths and an excluded column set to FALSE.

files |> filter_files(exclude = NULL) |> exclusions()
#> # A tibble: 9 × 2
#>   path                                              excluded
#>   <chr>                                             <lgl>   
#> 1 /tmp/RtmpxG7QUT/seekr-example/extdata/config.yaml FALSE   
#> 2 /tmp/RtmpxG7QUT/seekr-example/extdata/data.json   FALSE   
#> 3 /tmp/RtmpxG7QUT/seekr-example/extdata/iris.csv    FALSE   
#> 4 /tmp/RtmpxG7QUT/seekr-example/extdata/mtcars.csv  FALSE   
#> 5 /tmp/RtmpxG7QUT/seekr-example/extdata/script1.R   FALSE   
#> 6 /tmp/RtmpxG7QUT/seekr-example/extdata/script2.R   FALSE   
#> 7 /tmp/RtmpxG7QUT/seekr-example/extdata/server1.log FALSE   
#> 8 /tmp/RtmpxG7QUT/seekr-example/extdata/server2.log FALSE   
#> 9 /tmp/RtmpxG7QUT/seekr-example/extdata/server.png  FALSE

Find matches

Now that we have selected the files we want to search, we can look for matches with match_files().

The two central arguments are pattern and replacement. pattern defines what to look for. replacement is optional: when provided, it prepares replacement values for later, but it does not modify any file. Files are only modified when replace_files() is called.

match_files() also has a few arguments that control how files are read and how matches are recorded:

  • context controls how many lines before and after each match are stored in the seekr_match vector.
  • encoding controls how files are decoded. By default, files are read as UTF-8. Set encoding = NULL if you want seekr to try to detect the encoding with stringi::stri_enc_detect().

Regular expression patterns

Here we look for function names composed of two words separated by an underscore followed by <- function.

matches <- match_files(filtered, "([a-z]+)_([a-z]+)(?= <- function)")
matches
#> <seekr::match[5]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] ->  1 | add_one <- function(x) {
#> [2] ->  9 | say_hello <- function(name) {
#> 
#> script2.R [3]
#> [3] ->  2 | mean_safe <- function(x) {
#> [4] ->  7 | sd_safe <- function(x) {
#> [5] -> 12 | print_vector <- function(v) {

A plain character string is automatically treated as an ICU regular expression via stringr::regex(), as in stringr, except that multiline is set to TRUE by default. Because multiline = TRUE, anchors such as ^ and $ can match line boundaries inside a file.

These two calls are therefore equivalent.

match_files(filtered, pattern)

match_files(
  filtered,
  stringr::regex(
    "([a-z]+)_([a-z]+)(?= <- function)",
    ignore_case = FALSE,
    multiline = TRUE,
    comments = FALSE,
    dotall = FALSE
  )
)

For more control, pass a stringr pattern object directly. For example, we can make the search case-insensitive.

match_files(filtered, stringr::regex("FUNCTION", ignore_case = TRUE))
#> <seekr::match[7]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [3]
#> [1] ->  1 | add_one <- function(x) {
#> [2] ->  5 | capitalize <- function(txt) {
#> [3] ->  9 | say_hello <- function(name) {
#> 
#> script2.R [4]
#> [4] ->  1 | # TODO: optimize this function
#> [5] ->  2 | mean_safe <- function(x) {
#> [6] ->  7 | sd_safe <- function(x) {
#> [7] -> 12 | print_vector <- function(v) {

For more details about regular expressions in stringr, see the stringr regular expressions documentation.

Literal text patterns

If you want to search for literal text instead of a regular expression, use stringr::fixed().

match_files(filtered, stringr::fixed("<- function("))
#> <seekr::match[6]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [3]
#> [1] ->  1 | add_one <- function(x) {
#> [2] ->  5 | capitalize <- function(txt) {
#> [3] ->  9 | say_hello <- function(name) {
#> 
#> script2.R [3]
#> [4] ->  2 | mean_safe <- function(x) {
#> [5] ->  7 | sd_safe <- function(x) {
#> [6] -> 12 | print_vector <- function(v) {

This is useful when the text contains characters that would otherwise have a special meaning in a regular expression. Note that stringr::fixed() performs bytewise matching, so stringr::coll() may be more appropriate for locale-sensitive text.

For more details about the different pattern engines in stringr, see the stringr introduction.

Prepare replacements

Searching and replacing are separate steps in seekr.

You can prepare replacements when searching, but files are not modified until you call replace_files(). This makes it possible to search first, inspect the result, filter matches, update replacements if needed, and only then write changes to disk.

seekr offers five ways to prepare replacements during the matching step:

  • NULL, the default, to plan no replacement.
  • A plain string, used literally as replacement text.
  • A string with backreferences of the form \\1, \\2, and so on, replaced with the corresponding capture groups from pattern.
  • A function, called once per file with a character vector of all matches found in that file, and expected to return a character vector of the same length, such as toupper().
  • A function wrapped with with_capture_groups_matrix(), called once per file with a character matrix where the first column is the full match and the remaining columns are the capture groups.

Literal replacements

The simplest replacement is a string.

match_files(
  filtered,
  pattern = stringr::fixed("safe"),
  replacement = "checked"
)
#> <seekr::match[2]> 1 source
#> /tmp/RtmpxG7QUT/seekr-example/extdata/script2.R [2]
#> [1] -- 2 | mean_safe <- function(x) {
#>     ++ 2 | mean_checked <- function(x) {
#> [2] -- 7 | sd_safe <- function(x) {
#>     ++ 7 | sd_checked <- function(x) {

Here, every match of "safe" gets the same planned replacement: "checked".

Capture groups

Replacement strings can also refer to capture groups from a regular expression.

Here, we reverse the two parts of each function name.

match_files(filtered, "(\\w+)_(\\w+)(?= <- function)", "\\2_\\1")
#> <seekr::match[5]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | one_add <- function(x) {
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | hello_say <- function(name) {
#> 
#> script2.R [3]
#> [3] --  2 | mean_safe <- function(x) {
#>     ++  2 | safe_mean <- function(x) {
#> [4] --  7 | sd_safe <- function(x) {
#>     ++  7 | safe_sd <- function(x) {
#> [5] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {

Function replacements

A replacement can also be a function. The function receives a character vector of matched texts and must return one replacement value per match. It is vectorized over the matches found in a file; it is not called separately for each individual match.

match_files(filtered, "([a-z]+)_([a-z]+)(?= <- function)", toupper)
#> <seekr::match[5]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | ADD_ONE <- function(x) {
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | SAY_HELLO <- function(name) {
#> 
#> script2.R [3]
#> [3] --  2 | mean_safe <- function(x) {
#>     ++  2 | MEAN_SAFE <- function(x) {
#> [4] --  7 | sd_safe <- function(x) {
#>     ++  7 | SD_SAFE <- function(x) {
#> [5] -- 12 | print_vector <- function(v) {
#>     ++ 12 | PRINT_VECTOR <- function(v) {

Capture group matrix replacements

For more complex replacements, wrap a function with with_capture_groups_matrix().

The function receives the capture group matrix returned by the matching engine. The first column is the full match, and the following columns are the capture groups.

repl_with_matrix <- function(M) {
  # full_match <- M[, 1L]
  group1 <- M[, 2L]
  group2 <- M[, 3L]
  
  paste0(toupper(group2), "_", tolower(group1))
}

repl_with_matrix <- with_capture_groups_matrix(repl_with_matrix)
match_files(filtered, "([a-z]+)_([a-z]+)(?= <- function)", repl_with_matrix)
#> <seekr::match[5]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | ONE_add <- function(x) {
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | HELLO_say <- function(name) {
#> 
#> script2.R [3]
#> [3] --  2 | mean_safe <- function(x) {
#>     ++  2 | SAFE_mean <- function(x) {
#> [4] --  7 | sd_safe <- function(x) {
#>     ++  7 | SAFE_sd <- function(x) {
#> [5] -- 12 | print_vector <- function(v) {
#>     ++ 12 | VECTOR_print <- function(v) {

This is useful when the replacement logic depends on several captured parts of the match.

For workflows where text has already been read, or where you want to control reading and writing yourself, see Working with text.

Use seek() and seekr()

The previous sections used the lower-level steps explicitly. For common workflows, seek() combines listing, filtering, and matching in one call. seekr() is a shortcut around seek() with R, R Markdown, and Quarto extensions selected by default.

files <- list_files()
filtered <- filter_files(files, extension = "R")
x <- match_files(filtered, "(\\w+)_(\\w+)(?= <- function)", "\\2_\\1")

y <- seekr("(\\w+)_(\\w+)(?= <- function)", "\\2_\\1")

identical(x, y)
#> [1] TRUE

Note that:

  • the vector of matches returned by seekr() also contains the exclusions() attribute,
  • when no matches are found, the vector returned by seek()/seekr() also contains an empty_stage() attribute that helps explain where the workflow became empty.

Inspect the seekr_match vector

The result of match_files(), seek(), and seekr() is a seekr_match vector.

A seekr_match vector behaves like a vector of matches, but each match also stores fields such as the file path, match location, matched text, planned replacement, surrounding context, and encoding.

str(x)
#> <seekr::match[5]> vctrs::rcrd
#> path        <chr> "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R", "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R", "/tmp/RtmpxG7QUT/seekr-example
#> start_line  <int> 1, 9, 2, 7, 12
#> end_line    <int> 1, 9, 2, 7, 12
#> start       <int> 1, 107, 32, 119, 202
#> end         <int> 7, 115, 40, 125, 213
#> start_col   <int> 1, 1, 1, 1, 1
#> end_col     <int> 7, 9, 9, 7, 12
#> match       <chr> "add_one", "say_hello", "mean_safe", "sd_safe", "print_vector"
#> replacement <chr> "one_add", "hello_say", "safe_mean", "safe_sd", "vector_print"
#> before      <chr> NA, "\ncapitalize <- function(txt) {\n  toupper(substr(txt, 1, 1))\n}\n", "# TODO: optimize this function", "mean_safe <- function(x
#> line        <chr> "add_one <- function(x) {", "say_hello <- function(name) {", "mean_safe <- function(x) {", "sd_safe <- function(x) {", "print_vector
#> after       <chr> "  return(x + 1)\n}\n\ncapitalize <- function(txt) {\n  toupper(substr(txt, 1, 1))", "  paste('Hello', name)\n}\n", "  if (length(x)
#> encoding    <chr> "UTF-8", "UTF-8", "UTF-8", "UTF-8", "UTF-8"
#> hash        <chr> "e4cc5c4031699a911e6d5029cce6d71c", "e4cc5c4031699a911e6d5029cce6d71c", "036951bf4066a0b69595b7a0d9d0eb96", "036951bf4066a0b69595b7a

You can inspect the available fields with fields().

fields(x)
#>  [1] "path"        "start_line"  "end_line"    "start"       "end"         "start_col"   "end_col"     "match"       "replacement" "before"     
#> [11] "line"        "after"       "encoding"    "hash"

Individual fields can be accessed with field().

field(x, "match")
#> [1] "add_one"      "say_hello"    "mean_safe"    "sd_safe"      "print_vector"
field(x, "replacement")
#> [1] "one_add"      "hello_say"    "safe_mean"    "safe_sd"      "vector_print"
field(x, "path")
#> [1] "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R" "/tmp/RtmpxG7QUT/seekr-example/extdata/script1.R"
#> [3] "/tmp/RtmpxG7QUT/seekr-example/extdata/script2.R" "/tmp/RtmpxG7QUT/seekr-example/extdata/script2.R"
#> [5] "/tmp/RtmpxG7QUT/seekr-example/extdata/script2.R"

Use summary() to get a compact overview of the matches and planned replacements.

summary(x)
#> ── <seekr::match[5]> ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> Top sources [2]
#>  • script2.R : 3 (60.0%)
#>  • script1.R : 2 (40.0%)
#> 
#> Top matches/replacements [5]
#>  • <say_hello/hello_say>       : 1 (20.0%)
#>  • <add_one/one_add>           : 1 (20.0%)
#>  • <mean_safe/safe_mean>       : 1 (20.0%)
#>  • <sd_safe/safe_sd>           : 1 (20.0%)
#>  • <print_vector/vector_print> : 1 (20.0%)
#> 
#> Top extension [1]
#>  • r : 5 (100.0%)
#> 
#> Top encoding [1]
#>  • UTF-8 : 5 (100.0%)

Use print() to inspect matches with surrounding context and preview replacements before modifying files.

print(x, context = c(2L, 1L))
#> <seekr::match[5]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | one_add <- function(x) {
#>         2 |   return(x + 1)
#> 
#>         7 | }
#>         8 | 
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | hello_say <- function(name) {
#>        10 |   paste('Hello', name)
#> 
#> script2.R [3]
#>         1 | # TODO: optimize this function
#> [3] --  2 | mean_safe <- function(x) {
#>     ++  2 | safe_mean <- function(x) {
#>         3 |   if (length(x) == 0) return(NA)
#> 
#>         5 | }
#>         6 | 
#> [4] --  7 | sd_safe <- function(x) {
#>     ++  7 | safe_sd <- function(x) {
#>         8 |   if (length(x) <= 1) return(NA)
#> 
#>        10 | }
#>        11 | 
#> [5] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {
#>        13 |   print(paste('Vector of length', length(v)))

In terminals that support OSC8 hyperlinks, printed file locations can also be clickable.

For more on why seekr represents search results this way, see Design choices.

Filter matches

Because a seekr_match is a vector, it can be subset like any other R vector.

x[!grepl("safe", field(x, "match"))]
#> <seekr::match[3]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | one_add <- function(x) {
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | hello_say <- function(name) {
#> 
#> script2.R [1]
#> [3] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {

However, filter_match() is usually more convenient. It evaluates expressions directly on the fields of the seekr_match vector.

xf <- x |> filter_match(!grepl("safe", match))
xf
#> <seekr::match[3]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | one_add <- function(x) {
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | hello_say <- function(name) {
#> 
#> script2.R [1]
#> [3] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {

The result is still a seekr_match vector, so it can be printed, summarized, modified, or passed to replace_files().

print(xf, context = c(2L, 1L))
#> <seekr::match[3]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | one_add <- function(x) {
#>         2 |   return(x + 1)
#> 
#>         7 | }
#>         8 | 
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | hello_say <- function(name) {
#>        10 |   paste('Hello', name)
#> 
#> script2.R [1]
#>        10 | }
#>        11 | 
#> [3] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {
#>        13 |   print(paste('Vector of length', length(v)))

Use tabular workflows

For many workflows, filter_match() and field() are enough.

For more complex operations, it can be useful to convert a seekr_match vector to a tibble, work with it using tabular tools, and then convert it back to a seekr_match vector before replacing files.

This is especially useful for grouped summaries, joins, group-aware filtering, or replacement logic that is easier to express with dplyr.

When converting back, as_match() validates that the tibble still contains the fields required to reconstruct a valid seekr_match vector. This makes it possible to use tabular workflows while still returning to the main seekr replacement workflow.

For examples, see the as_match() documentation and the article Tabular workflows.

Update replacements after inspection

You do not need to decide every replacement when searching.

The replacement field can be set or updated after matches have been inspected or filtered.

first_path <- field(xf, "path")[[1L]]
repl <- field(xf, "replacement")

field(xf, "replacement") <- ifelse(
  field(xf, "path") == first_path,
  toupper(repl),
  tolower(repl)
)

print(xf, context = c(2L, 1L))
#> <seekr::match[3]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | ONE_ADD <- function(x) {
#>         2 |   return(x + 1)
#> 
#>         7 | }
#>         8 | 
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | HELLO_SAY <- function(name) {
#>        10 |   paste('Hello', name)
#> 
#> script2.R [1]
#>        10 | }
#>        11 | 
#> [3] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {
#>        13 |   print(paste('Vector of length', length(v)))

This makes it possible to search broadly, inspect the result, keep only the matches that matter, and then decide what each selected match should become.

Replace selected matches

Important: replace_files() writes modified files in UTF-8. If you need to control how files are written, use the workflow described in Working with text.

When the selected matches and replacements look right, we can call replace_files() which starts from the current seekr_match vector.

If you found five matches, filtered the vector down to three matches, and updated their replacements, only those three remaining matches are replaced, each with its corresponding replacement.

Before writing, replace_files() checks that every selected match has a replacement and that the hash of each affected file still matches the hash recorded when the seekr_match vector was created. If a file has changed since the search, replacement stops before writing that file, and the search should be run again on the current file contents.

If you created several seekr_match vectors from the same file state, combine them before replacing. After the first replacement, the file hash changes, so a second call using matches from the old file state will fail. For example, use replace_files(c(x, y)) instead of calling replace_files(x) and then replace_files(y).

By default, backup = TRUE, so each file that will be modified is backed up before it is written. A description can be provided to make the backup easier to identify later. The backup directory can also be changed with backup_dir or the seekr.backup_dir option.

replaced <- replace_files(xf, description = "Inverse function names")
replaced
#> <seekr::match[3]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] --  1 | add_one <- function(x) {
#>     ++  1 | ONE_ADD <- function(x) {
#> [2] --  9 | say_hello <- function(name) {
#>     ++  9 | HELLO_SAY <- function(name) {
#> 
#> script2.R [1]
#> [3] -- 12 | print_vector <- function(v) {
#>     ++ 12 | vector_print <- function(v) {

After replacement, we can search again.

seekr("([a-z]+)_([a-z]+)(?= <- function)") |> print(context = c(0L, 3L))
#> <seekr::match[3]> 1 source
#> /tmp/RtmpxG7QUT/seekr-example/extdata/script2.R [3]
#> [1] ->  2 | mean_safe <- function(x) {
#>         3 |   if (length(x) == 0) return(NA)
#>         4 |   mean(x, na.rm = TRUE)
#>         5 | }
#> 
#> [2] ->  7 | sd_safe <- function(x) {
#>         8 |   if (length(x) <= 1) return(NA)
#>         9 |   sd(x, na.rm = TRUE)
#>        10 | }
#> 
#> [3] -> 12 | vector_print <- function(v) {
#>        13 |   print(paste('Vector of length', length(v)))
#>        14 | }
#>        15 | 

Restore files

After modifying files, backups can be inspected with list_backups(), and the most recent backup can be retrieved with last_backup().

bck <- last_backup()
bck
#> # A tibble: 2 × 9
#>      id created_at          operation description            original                                       backup original_exists backup_exists  size
#>   <int> <dttm>              <chr>     <chr>                  <chr>                                          <chr>  <lgl>           <lgl>         <fs:>
#> 1     1 2026-07-11 20:15:22 replace   Inverse function names /tmp/RtmpxG7QUT/seekr-example/extdata/script1… /tmp/… TRUE            TRUE            161
#> 2     1 2026-07-11 20:15:22 replace   Inverse function names /tmp/RtmpxG7QUT/seekr-example/extdata/script2… /tmp/… TRUE            TRUE            279

Use restore_files() to restore the previous file contents.

restore_files(
  from = bck$backup, 
  to = bck$original,
  description = "restore after reversing function names by mistake"
)
#>  Creating a backup of the current version of each existing destination file before restoring it.
#>  This ensures you can revert to the state before restoration if needed.

By default, restoring files also creates a backup before writing, so both operations remain available in the backup history. This makes it possible to undo a replacement, while still keeping a record of the files that were present just before the restore operation.

list_backups()
#> # A tibble: 4 × 9
#>      id created_at          operation description                                       original            backup original_exists backup_exists  size
#>   <int> <dttm>              <chr>     <chr>                                             <chr>               <chr>  <lgl>           <lgl>         <fs:>
#> 1     2 2026-07-11 20:15:23 restore   restore after reversing function names by mistake /tmp/RtmpxG7QUT/se… /tmp/… TRUE            TRUE            161
#> 2     2 2026-07-11 20:15:23 restore   restore after reversing function names by mistake /tmp/RtmpxG7QUT/se… /tmp/… TRUE            TRUE            279
#> 3     1 2026-07-11 20:15:22 replace   Inverse function names                            /tmp/RtmpxG7QUT/se… /tmp/… TRUE            TRUE            161
#> 4     1 2026-07-11 20:15:22 replace   Inverse function names                            /tmp/RtmpxG7QUT/se… /tmp/… TRUE            TRUE            279

After restoring, the original matches are back.

after_restore <- seekr("([a-z]+)_([a-z]+)(?= <- function)")
print(after_restore, context = c(2L, 1L))
#> <seekr::match[5]> 2 sources
#> Common Path: /tmp/RtmpxG7QUT/seekr-example/extdata
#> 
#> script1.R [2]
#> [1] ->  1 | add_one <- function(x) {
#>         2 |   return(x + 1)
#> 
#>         7 | }
#>         8 | 
#> [2] ->  9 | say_hello <- function(name) {
#>        10 |   paste('Hello', name)
#> 
#> script2.R [3]
#>         1 | # TODO: optimize this function
#> [3] ->  2 | mean_safe <- function(x) {
#>         3 |   if (length(x) == 0) return(NA)
#> 
#>         5 | }
#>         6 | 
#> [4] ->  7 | sd_safe <- function(x) {
#>         8 |   if (length(x) <= 1) return(NA)
#> 
#>        10 | }
#>        11 | 
#> [5] -> 12 | print_vector <- function(v) {
#>        13 |   print(paste('Vector of length', length(v)))