### ----------------------------------------------------
### -- App::Rak
### -- Licenses: Artistic-2.0
### -- Authors: Elizabeth Mattijsen
### -- File: lib/App/Rak.rakumod
### ----------------------------------------------------
### -- Chunk 1 of 4
# The modules that we need here, with their full identities
use as-cli-arguments:ver<0.0.8+>:auth<zef:lizmat>;   # as-cli-arguments
use IO::Path::AutoDecompress:ver<0.0.3+>:auth<zef:lizmat>; # IOAD
use JSON::Fast::Hyper:ver<0.0.9+>:auth<zef:lizmat>; # from-json to-json
use META::constants:ver<0.0.5+>:auth<zef:lizmat> $?DISTRIBUTION;
use rak:ver<0.0.65+>:auth<zef:lizmat>;              # rak Rak

use Backtrace::Files:ver<0.0.4+>:auth<zef:lizmat> <
  backtrace-files
>;
use highlighter:ver<0.0.21+>:auth<zef:lizmat> <
  columns highlighter
>;
use Needle::Compile:ver<0.0.9+>:auth<zef:lizmat> <
  compile-needle implicit2explicit StrType Type
>;
use String::Utils:ver<0.0.32+>:auth<zef:lizmat> <
  after before between has-marks is-sha1 non-word paragraphs regexify
>;

# Returns the names of branch in current git repo
my sub branches(|) { # Until this lives in a module somewhere
    (run <git branch -r>, :out).out.lines.map(*.&after("/"))
}

# The epoch value when process started
my $init-epoch = $*INIT-INSTANT.to-posix.head;

# Defaults for highlighting on terminals
my constant BON  = "\e[1m";   # BOLD ON
my constant BOFF = "\e[22m";  # BOLD OFF

#- start of available options --------------------------------------------------
#- Generated on 2025-02-11T22:18:16+01:00 by tools/makeOPTIONS.raku
#- PLEASE DON'T CHANGE ANYTHING BELOW THIS LINE
my str @options = <absolute accept accessed ack after-context allow-loose-escapes allow-loose-quotes allow-whitespace also-first always-first and andnot auto-decompress auto-diag backtrace backup batch before-context blame-per-file blame-per-line blocks break categorize checkout classify context count-only created csv-per-line degree deny description device-number dir dont-catch dryrun eco-code eco-doc eco-meta eco-provides eco-scripts eco-tests edit encoding eol escape exec execute-raku extensions file file-separator-null files-from files-with-matches files-without-matches filesize find formula frequencies gid group group-matches hard-links has-setgid has-setuid headers help highlight highlight-after highlight-before human ignorecase ignoremark inode invert-match is-empty is-executable is-group-executable is-group-readable is-group-writable is-moarvm is-owned-by-group is-owned-by-user is-owner-executable is-owner-readable is-owner-writable is-pdf is-readable is-sticky is-symbolic-link is-text is-world-executable is-world-readable is-world-writable is-writable json-per-elem json-per-file json-per-line keep-meta list-custom-options list-expanded-options list-known-extensions matches-only max-matches-per-file mbc mbc-frames mbc-strings meta-modified mode modifications modified modify-files module not only-first or ornot output-dir output-file pager paragraph-context passthru passthru-context paths paths-from pattern patterns-from pdf-info pdf-per-file pdf-per-line per-file per-line per-paragraph progress proximate rakudo-all rakudo-c rakudo-doc rakudo-java rakudo-js rakudo-nqp rakudo-perl rakudo-raku rakudo-shell rakudo-test rakudo-yaml rename-files quietly quote rak recurse-symlinked-dir recurse-unmatched-dir repository save sayer sep shell show-blame show-filename show-item-number silently smartcase smartmark sourcery stats stats-only strict summary-if-larger-than trim type uid under-version-control unicode unique user verbose version vimgrep with-line-endings>;
#- PLEASE DON'T CHANGE ANYTHING ABOVE THIS LINE
#- end of available options ----------------------------------------------------

# Options of other programs that may be false friends
my constant %falsies =
# our own
  changed          => 'meta-modified',
  run              => 'exec',
  first-only       => 'only-first',
  known-extensions => 'extensions=*',

# from ack
  A                => 'after-context',
  B                => 'before-context',
  C                => 'context',
  c                => 'count-only',
  count            => 'count-only',
  dump             => 'list-expanded-options',
  f                => 'find',
  follow           => 'recurse-symlinked-dir',
  group            => 'group-matches',
  H                => 'show-filename',
  with-filename    => 'show-filename',
  h                => 'no-show-filename',
  filename         => 'show-filename',
  heading          => 'group-matches',
  help-types       => 'extensions=*',
  i                => 'ignorecase',
  ignore-case      => 'ignorecase',
  I                => 'no-ignorecase',
  ignore-dir       => 'dir',
  ignore-directory => 'dir',
  k                => 'extensions=*',
  known-types      => 'extensions=*',
  l                => 'files-with-matches',
  L                => 'files-without-matches',
  match            => 'pattern',
  m                => 'max-matches-per-file',
  max-count        => 'max-matches-per-file',
  man              => 'help',
  o                => 'matches-only',
  p                => 'proximate',
  P                => 'proximate',
  output           => 'pattern',
  print0           => 'file-separator-null',
  S                => 'smartcase',
  smart-case       => 'smartcase',
  t                => 'extensions',
  type             => 'extensions',
  TYPE             => 'extensions',
  v                => 'invert-match',
  x                => 'files-from',
  1                => 'only-first',

# from ag
  a                => 'dir',
  all-types        => 'dir',
  after            => 'after-context',
  before           => 'before-context',
  0                => 'file-separator-null',
  null             => 'file-separator-null',
  u                => 'dir',
  unrestricted     => 'dir',
;

# The JSON config to map "ack" arguments to "rak" arguments as closely as
# possible
my constant %ack-interface-config =
  "1"                => (:only-first,),
  "A"                => (:after-context<!>,),
  "B"                => (:before-context<!>,),
  "c"                => (:count-only,),
  "C"                => (:context<[2]>,),
  "count"            => (:count-only,),
  "dump"             => (:list-expanded-options,),
  "f"                => (:find,),
  "filename"         => (:show-filename,),
  "follow"           => (:recurse-symlinked-dir,),
  "group"            => (:group-matches,),
  "h"                => (:!show-filename,),
  "H"                => (:show-filename,),
  "heading"          => (:group-matches,),
  "help-types"       => (:extensions<[*]>,),
  "i"                => (:ignorecase,),
  "I"                => (:!ignorecase,),
  "ignore-case"      => (:ignorecase,),
  "ignore-dir"       => (:dir,),
  "ignore-directory" => (:dir,),
  "k"                => (:extensions<[*]>,),
  "known-types"      => (:extensions<[*]>,),
  "l"                => (:files-with-matches,),
  "L"                => (:files-without-matches,),
  "m"                => (:max-matches-per-file,),
  "n"                => (:!dir,),
  "man"              => (:help,),
  "match"            => (:pattern,),
  "max-count"        => (:max-matches-per-file,),
  "o"                => (:matches-only,),
  "output"           => (:pattern,),
  "p"                => (:proximate<!>,),
  "P"                => (:proximate<0>,),
  "print0"           => (:file-separator-null,),
  "S"                => (:smartcase,),
  "smart-case"       => (:smartcase,),
  "t"                => (:extensions<!>,),
  "type"             => (:extensions<!>,),
  "TYPE"             => (:extensions<!>,),
  "v"                => (:invert-match,),
  "w"                => (:type<words>,),
  "with-filename"    => (:show-filename,),
  "x"                => (:files-from<->,),
;

# Options that only make sense after one main option
my constant %sub-options =
  allow-loose-escapes => 'csv-per-line',
  allow-loose-quotes  => 'csv-per-line',
  allow-whitespace    => 'csv-per-line',
  auto-diag           => 'csv-per-line',
  eol                 => 'csv-per-line',
  escape              => 'csv-per-line',
  formula             => 'csv-per-line',
  quote               => 'csv-per-line',
  sep                 => 'csv-per-line',
  strict              => 'csv-per-line',
  keep-meta           => 'csv-per-line',
;

# IO subroutines convertining Instants to epoch transparently
my sub accessed($io)      { $io.accessed.to-posix.head }
my sub created($io)       { $io.created.to-posix.head  }
my sub meta-modified($io) { $io.changed.to-posix.head  }
my sub modified($io)      { $io.modified.to-posix.head }

# Multiplication values for time based filtering
my constant %mult =
  s => 1,                 # seconds
  m => 60,                # minutes
  h => 60 * 60,           # hours
  d => 24 * 60 * 60,      # days
  w => 7 * 24 * 60 * 60,  # weeks
;

# Convert a time specification into number of seconds
my sub seconds($format) {
    my int $seconds;
    for $format.split(
      / <[smhdw]> /, :v, :skip-empty,
    ) -> Int() $value, Str() $type = "d" {
        $seconds += $value * %mult{$type};
    }
    $seconds
}

# Make sure we remember if there's a human watching (terminal connected)
my $reading-from-stdin := !$*IN.t;
my $writing-to-stdout  := $*OUT.t;
my $writing-to-stderr  := $*ERR.t;

# Set up default extension sets
my constant %exts =
  '#c'        => <c h hdl>,
  '#c++'      => <cpp cxx hpp hxx>,
  '#csv'      => ('', <csv psv tsv>).flat.List,
  '#cro'      => ('', 'crotmp'),
  '#html'     => <htm html css>,
  '#js'       => <js ts tsx>,
  '#json'     => <json>,
  '#jsonl'    => <jsonl>,
  '#markdown' => <md markdown>,
  '#perl'     => ('', <pl pm t>).flat.List,
  '#python'   => <py ipynb>,
  '#raku'     => ('', <raku rakumod rakutest rakudoc nqp t pm6 pl6 pod6 t6>
                 ).flat.List,
  # I am not sure should the binary formats be included or not:
  # https://reference.wolfram.com/language/guide/WolframLanguageFileFormats.html
  # Also #mathematica can be included as synonym of #wl .
  '#wl'     => ('', <wl wlt m mt nb>).flat.List,
  # I am including .Rmd because of .ipynb inclusion above.
  # More generally, the extensions of the project https://quarto.org should be included.
  '#r'     => ('', <r R Rmd>).flat.List,
  '#ruby'     => <rb>,
  '#text'     => ('', <txt>).flat.List,
  '#yaml'     => <yaml yml>,
  "#config"   => <ini>,
;

# Known extensions
my constant @known-extensions = %exts.values.flat.unique.sort;

# Place to keep tagged configurations
my $config-file := do if %*ENV<RAK_CONFIG> -> $rak-config {
    $rak-config.IO
}
elsif %*ENV<RAK_CONFIG>:!exists {  # want to have the default config
    $*HOME.add('.rak-config.json')
}

# Links to optional classes
my $GitBlameFile;
my $MoarVMBytecode;
my $PDFExtract;
my $TextCSV;
my &edit-files;
my &sourcery;
my &sourcery-pattern;

# Variables for grouping options given
my $verbose;      # process verbose
my $pager;        # process pager if defined
my $output-file;  # process output file if defined
my $output-dir;   # process output directory if defined
my $debug-rak;    # process show rak args

my @patterns;      # the specified patterns (if any)
my @highlights;    # the pattern used for highlighting
my $seen-initial-pattern;  # flag, --pattern/--patterns-from seen
my $matches-only;  # whether to produce matches only
my $smartcase;     # --smartcase
my $smartmark;     # --smartmark
my $ignorecase;    # --ignorecase
my $ignoremark;    # --ignoremark
my $modifications := True;  # --modifications

my $type;  # --type (implicitely) specified

my @modules;  # list of modules to -use-
my @repos;    # list of repositories to include with -use lib-

my $source-for;         # name of option providing sources
my $source-rak;         # name of rak option providing sources
my $source;             # associated value (if any)
my int $source-offset;  # start of source to be shown

my $producer-for;  # name of option providing producer

my $action-for;  # name of option to perform
my $action;      # associated value (if any)

my %global;      # global arguments
my %filesystem;  # filesystem selection args
my %result;      # result modifier options specified
my %listing;     # listing options specified
my %csv;         # arguments needed for --csv-per-line
my %modify;      # arguments needed for --modify-files

my $needle;  # Callable needle for rak
my %rak;     # arguments to be sent to rak()
my $rak;     # the result of calling rak()

my $enc;      # actual encoding to be used inside rak
my $headers;  # actual headers arg for CSV
my %CSV;      # actual CSV arguments sent to rak

# For now, the routine for outputting anything
my &sayer = do {
    my $out := $*OUT;
    -> $_ { $out.say($_) }
}

# Role for marking default values in expanded parameters
my role is-default-option { method gist(--> Nil) { } }

# Fetch and normalize any config, we only do List of Pairs nowadays
my %config := do {
    if $config-file && $config-file.e {
        my %hash := from-json($config-file.slurp);

        # fix various save snafus and content changes
        for %hash.values {
            $_ = .pairs.List if Hash.ACCEPTS($_);
            if Array.ACCEPTS($_) {
                $_ = .map(*.pairs.Slip).List;
            }
        }
        if %hash<(default)> -> @defaults {
            $_ does is-default-option for @defaults.map(*.value);
        }
        %hash
    }
    else {
        { }
    }
}

my @positionals; # Positional arguments
my @unexpected;  # Pairs of unexpected arguments and their value

# Save current setting
my constant $safe-marker = '--save=';
if @*ARGS.first(*.starts-with($safe-marker)) -> $custom {

    meh "Cannot save: configuration file location explicitely disabled"
      unless $config-file;

    my @opts;
    for @*ARGS -> $arg {
        if $arg.starts-with($safe-marker) {
            # no action
        }
        elsif $arg eq '-' {
            @positionals.push: $arg;
        }
        elsif $arg.match(
          /^ '-' '-'? ('/' | 'no-')? (<-[=]>+) ('='?) (.*) /
        ) -> $/ {
            @opts.push: Pair.new: $1.Str, $2.Str ?? $3.Str !! $0.defined.not;
        }
        elsif $arg.starts-with('-') {
            meh "Improperly formatted option: '$arg')";
        }
        else {
            @positionals.push: $arg;
        }
    }
    meh-unexpected if @unexpected;

    my $name := $custom.substr($safe-marker.chars);
    if @opts {
        @opts.append: %config{$name}.grep(*.key ne 'description')
          if @opts == 1 && @opts.head.key eq 'description';
        %config{$name} := @opts;
    }
    else {
        %config{$name}:delete;
    }

    $config-file.spurt: to-json %config, :!pretty, :sorted-keys;

    say @opts
          ?? "Saved '&as-cli-arguments(@opts)' as: &o($name)"
          !! "Removed custom option '&o($name)'";
        exit;
}

# Make sure defaults are activated unless we're saving
elsif %config<(default)>:exists {
    named('(default)', True);
}

# Helper sub to add a highlight spec
my sub add-highlight($_) {

    # Make sure highlightable patterns are added
    if .key eq 'contains' | 'words' | 'starts-with' | 'ends-with' {
        @highlights.push: $_;
    }
    elsif .key eq 'regex' {
        @highlights.push: regexify .value,
          :$ignorecase, :$smartcase, :$ignoremark, :$smartmark
    }
    elsif .key eq 'split' {
        add-highlight(implicit2explicit($_)) for .value.words;
    }
    elsif .key eq 'and' {
        add-highlight(.value)
    }
}

# Helper sub to add a pattern
my sub add-pattern($pattern, :$first --> Nil) {
    $_ := $type && $type ne "auto"
      ?? Pair.new($type, $pattern)
      !! implicit2explicit($pattern);

    $first
      ?? @patterns.unshift($_)
      !! @patterns.push($_);

    # Make sure highlightable patterns are added
    add-highlight($_);
}

#-------------------------------------------------------------------------------
# Actually set up all variables from the arguments specified and run.
# Theory of operation:
#
# 1. Loop over all of the strings in @ARGS
#     - does it NOT start with "-"?  -> positional argument
#     - named argument: call "set-$name" with the given value
#     - add to unexpected if sub doesn't exist
# 2. See of an action name has been set, of not: assume 'per-line'
# 3. Run the "action-$name" sub
# 4. Close STDOUT if a pager was used

my sub main() is export {

    # Must have something to work with
    meh q:to/MEH/.chomp unless @*ARGS || @patterns;
Should at least specify a pattern.  For instance:

  rak foo

will look for "foo" in all text files in the current directory
and any sub-directories. See:

  rak --help

for (much) more information.
MEH

    # Do the actual argument parsing
    for @*ARGS {

        # looks like an option
        if .starts-with('-') {

            # Allow -j2 as an alternative to --j=2, aka :numeric-suffix-as-value
            $_ = "-$_.substr(0,2)=$/" if .match: /^ '-' <.alpha> <( \d+ $/;

            if .starts-with('--/') {
                my ($before,$after) = .substr(3).split('=',2);
                named $before, $after // False;
            }
            elsif .starts-with('--no-') {
                my ($before,$after) = .substr(5).split('=',2);
                named $before, $after // False;
            }
            elsif .starts-with('--') {
                my ($before,$after) = .substr(2).split('=',2);
                named $before, $after // True;
            }
            elsif .starts-with('-/') {
                my ($before,$after) = .substr(2).split('=',2);
                if $before.chars == 1 {
                    named $before, $after // False;
                }
                elsif $after.defined {
                    named $_, $after for $before.comb;
                }
                else {
                    named $_, False for $before.comb;
                }
            }

            # a bare - considered to be a positional
            elsif $_ eq '-' {
                @positionals.push: $_;
            }

            else {  # .starts-with('-')
                my ($before,$after) = .substr(1).split('=',2);
                if $before.chars == 1 {
                    named $before, $after // True;
                }
                elsif $after.defined {
                    named $_, $after for $before.comb;
                }
                else {
                    named $_, True for $before.comb;
                }
            }
        }

        # not an option
        else {
            @positionals.push: $_;
        }
    }

    # huh?
    meh-unexpected if @unexpected;

    # Set up the pattern
    add-pattern(@positionals.shift, :first)
      if !$seen-initial-pattern && @positionals;

    # from here on out, description is a noop
    %global<description>:delete;

    if %global<list-expanded-options>:delete {
        if as-options() -> @options {
            if $verbose {
                for @options {
                    if description(.key) -> $description {
                        say "&as-cli-arguments($_): $description";
                    }
                    else {
                        say as-cli-arguments($_);
                    }
                }
            }
            else {
                say as-cli-arguments as-options;
            }
        }
        else {
            say "No options found";
        }
        exit;
    }

    # STDIN sanity checking
    if @positionals && @positionals.head eq '-' {
        if $reading-from-stdin {
            @positionals.shift;
            meh "Cannot specify additional paths when reading from STDIN"
              if @positionals;
        }
        else {
            $reading-from-stdin := True;
        }
    }
    elsif @positionals == 1 && @positionals.head.IO.f {
        %listing<show-filename> := False
          if %listing<show-filename>:!exists
          && !(%result<categorize classify>:k);
    }
    elsif $reading-from-stdin {
        %listing<show-filename> := False
          unless %result<show-filename>:exists;
        %result<show-item-number> := False
          unless %result<show-item-number>:exists;
    }

    # Perform the actual action
    $action-for ?? ::("&action-$action-for")() !! action-per-line();
    $*OUT.close if $pager;
}

# no mainline code from here
#-------------------------------------------------------------------------------

# Return "s" if number is not 1, for error messages
my sub s($elems)  { $elems == 1 ?? '' !! 's'  }
my sub es($elems) { $elems == 1 ?? '' !! 'es' }

# Properly show an option with one or two dashes
sub o($option) {
    $option eq '(default)'
      ?? $option
      !! $option.chars == 1
        ?? "-$option"
        !! "--$option"
}

# Return '--a, --b' for one or more names
my sub mm(@names) { @names.map({"--$_"}).join(', ') }

# Sane way of quitting
my sub meh($message) is hidden-from-backtrace {
    exit note $message.ends-with('.' | '?')
      ?? $message
      !! "$message.";
}

# Quit if module not installed
my sub meh-not-installed($module, $feature) is hidden-from-backtrace {
    meh qq:to/MEH/.chomp;
Must have the $module module installed to do $feature.
You can do this by running 'zef install $module'.
MEH
}

# Quit if a code compilation error occurred
my sub meh-compilation($code, $_) {
    %rak<dont-catch>
      ?? .throw
      !! meh "Could not compile '$code':\n$_.message()";
}

# Return string before marker, or string if no marker
my sub before-or-string(str $string, str $marker) {
    before($string, $marker) // $string
}

# Return extension of filename, if any
my sub extension(str $filename) {
    with rindex($filename, '.') {
        lc substr($filename, $_ + 1)
    }
    else {
        ""
    }
}

# Process all alternate names / values into a Map and remove them
my sub named-args(%args, *%wanted) {
    Map.new: %wanted.kv.map: -> $name, $keys {
        if $keys =:= True {
            Pair.new($name, %args.DELETE-KEY($name))
              if %args.EXISTS-KEY($name);
        }
        orwith $keys.first: { %args.EXISTS-KEY($_) }, :k {
            Pair.new($name, %args.DELETE-KEY($keys.AT-POS($_)))
        }
        elsif %args.EXISTS-KEY($name) {
           Pair.new($name, %args.DELETE-KEY($name))
        }
    }
}

# Convert a string to code if possible, adhering to type
my sub codify(Str:D $code) {
    CATCH { meh-compilation($code, $_) }

    my $callable := compile-needle($code,
      :$ignorecase, :$smartcase, :$ignoremark, :$smartmark, :@repos, :@modules
    );
    $callable ~~ Failure
      ?? meh-compilation($code, $callable.exception)
      !! $callable
}

# Convert a string to non-Regex code, fail if not possible
my sub convert-to-simple-Callable(Str:D $code, str $name) {
    my $type := implicit2explicit($code).key;

    $type eq 'regex'
      ?? meh("Cannot use a regular expression for --$name: '$code'")
      !! $type eq 'contains'
        ?? Nil
        !! compile-needle($code,
             :$ignorecase, :$smartcase, :$ignoremark, :$smartmark, :@repos, :@modules
           )
}

# Return a Seq with ~ paths substituted for actual home directory paths
my sub homify($from) {
    my $home := $*HOME.absolute ~ '/';
    ($from eq "-"
      ?? $*IN.lines
      !! $from.subst(/^ '~' '/'? /, $home).IO.lines
    ).map:
      *.subst(/^ '~' '/'? /, $home)
}

# Drop the "location" of the warning, as it serves no useful purpose here
my sub drop-location-from-warning($warning) {
    note $warning.gist.lines.grep(!*.starts-with('  in block')).join("\n");
    $warning.resume;
}

# Change list of conditions into a Callable for :file
my sub codify-extensions(*@extensions) {
    if @extensions == 1 {
        my $extension := @extensions.head;
        -> $_ { !is-sha1($_) && extension($_) eq $extension }
    }
    else {
        -> $_ { !is-sha1($_) && extension($_) (elem) @extensions }
    }
}

# Return absolute path to ecosystem cache
my sub ecosystem-cache(str $ecosystem, str $dir) {
    my $io := ((%*ENV<RAKU_ECOSYSTEM_CACHE> andthen .IO)
      // ($*HOME // $*TMPDIR).add(".ecosystem").add("cache")
    ).add($ecosystem);

    $source-offset = $io.relative.chars + 3;
    $io.add($dir).absolute
}

# Return absolute path to rakudo cache
my sub rakudo-cache(str $type) {
    # This is a little brittle
    $source-offset = $*EXECUTABLE.parent(3).absolute.chars + 1;
    %listing<absolute> := True;

    ((%*ENV<RAKU_RAKUDO> andthen .IO) // ($*HOME // $*TMPDIR))
      .add(".raku").add("cache").add("rakudo-$type").absolute
}

#-------------------------------------------------------------------------------

# Run the query
my sub run-rak(:$eagerly --> Nil) {

    meh "Searching for binary data NYI, did you forget a --find?"
      if %rak<is-text>:exists && !%rak<is-text> && !%rak<find>;

    if (
      %global.keys, %result.keys, %csv.keys, %modify.keys
    ).flat -> @unhandled {
        note qq:to/TEXT/;
The &mm(@unhandled) option&s(@unhandled) {@unhandled == 1 ?? "is" !! "are"} not being handled and will be ignored.
If you believe this to be incorrect, please report an issue with
https://github.com/lizmat/App-Rak/issues/new .
TEXT
    }

    %rak<eager> := True if $eagerly;
    if %rak<sources-only>
      || %rak<sources-without-only>
      || (%rak<max-matches-per-source>:exists) {
    }
    else {
        %rak<max-matches-per-source> := %listing<only-first>:exists
          ?? %listing<only-first>
          !! (%listing<human> // $writing-to-stdout)
            ?? 1000
            !! 0;
    }

    if $debug-rak {
        note .key ~ ': ' ~ .value.raku for %rak.sort(*.key);
    }

    $rak := rak $needle, %rak;
    meh .message with $rak.exception;
    note "Unexpected leftovers: %rak.raku()" if %rak;
}

# Process the results
my sub rak-results(--> Nil) {
    $output-dir ?? output-dir-results() !! show-results();
}

# Wanna save the classification / categorization
my sub output-dir-results(--> Nil) {
    my @keys;
    for $rak.result {
        if Pair.ACCEPTS($_) {
            if .value -> @matches {
                my $file := .key;
                my $id   := ($file || '(empty)').IO.basename;
                @keys.push: $id;
                my $io := $output-dir.add($id);
                $io.spurt: ("\n\n" x $io.e)
                  ~ (IO::Path.ACCEPTS($file) ?? $file.absolute !! $id)
                  ~ "\n"
                  ~ @matches.join("\n"),
                  :append;
            }
        }
        else {
            die $_;  # huh?
        }
    }
    if $verbose {
        sayer "Created @keys.elems() file&s(@keys) in '$output-dir':\n  @keys.join("\n  ")";
    }
    else {
        sayer "Created @keys.elems() file&s(@keys) in '$output-dir'";
    }
}

# Showing results the normal way
my sub show-results(--> Nil) {
    my $human         := %listing<human>:delete // $writing-to-stdout;
    my $show-filename := %listing<show-filename>:delete // True;
    my $absolute      := %listing<absolute>:delete;
    my $break         := %listing<break>:delete;
    my $group-matches := %listing<group-matches>:delete;
    my $highlight     := %listing<highlight>:delete;
    my $trim          := %listing<trim>:delete;
    my $only-first    := %listing<only-first>:delete;
    my uint $proximate = %listing<proximate>:delete // 0;

    # Set up human defaults
    if $human {
        $break         := ""   unless $break.defined;
        $group-matches := True unless $group-matches.defined;
        $highlight     := !$matches-only unless $highlight.defined;
        $trim          := True unless $trim.defined;
        $only-first    := 1000 unless $only-first.defined;
    }
    my $has-break := %listing<has-break>:delete // $break.defined;
    # Switch to really large values if not specified
    my uint $stop-after = $only-first || 0x7fff_ffff_ffff_ffff;

    # Set up highlighting
    my $highlight-before;
    my $highlight-after;
    with %listing<highlight-before>:delete {
        $highlight-before := $_;
        $highlight        := True;
    }
    with %listing<highlight-after>:delete {
        $highlight-after := $_;
        $highlight       := True;
    }
    else {
        $highlight-after := $highlight-before;
    }

    my Str() $pre = my Str() $post = $_ with $highlight-before;
    $post                          = $_ with $highlight-after;
    $pre  = BON  without $pre;
    $post = BOFF without $post;

    my &line-post-proc := do if $highlight && @highlights {

        my %nameds =
          (:$smartcase  if $smartcase),
          (:$ignorecase if $ignorecase),
          (:$smartmark  if $smartmark),
          (:$ignoremark if $ignoremark),
          (:summary-if-larger-than($_)
            with %listing<summary-if-larger-than>:delete),
        ;

        $trim
          ?? -> $line {
                 highlighter $line.trim, @highlights, $pre, $post, |%nameds
             }
          !! -> $line {
                 highlighter $line, @highlights, $pre, $post, |%nameds
             }
    }

    elsif %listing<whole-line>:delete {
        $output-dir
          ?? -> $line { $line }
          !! -> $line { "$pre$line$post" }
    }

    # No highlighting wanted, abuse highlighter logic anyway
    else {
        $trim ?? *.Str.trim !! *.Str
    }

    # Set way to stringify paths
    my &path-stringify :=
      IO::Path.^find_method($absolute ?? "absolute" !! "relative");
    my sub stringify($value) {
        $value.WHAT =:= IO::Path  # only "pure" IO::Path objects
          ?? path-stringify($value)
          !! Buf.ACCEPTS($value)
            ?? $value.List.Str
            !! $value.Str
    }

    # Proximate breaker logic
    my sub breaker(uint $linenr, uint $last-linenr --> Nil) {
        sayer ""
          if $proximate
          && ($linenr <= $last-linenr  # probably a backtrace
               |! ($linenr - $last-linenr) >= $proximate);
    }

    # show the results!
    my int $seen;
    RESULT:
    for $rak.result -> $outer {
        if Pair.ACCEPTS($outer) {
            my $key   := $outer.key;
            my $value := $outer.value;

            # Just listing paths
            if $key eq '<find>' {
                for @$value.sort(*.fc) {
                    sayer line-post-proc stringify($_);
                        last RESULT if ++$seen == $stop-after;
                }
            }

            # Looks like normal search result
            elsif Iterable.ACCEPTS($value) {
                if $value -> @matches {
                    my $source :=
                      stringify($key).substr($source-offset) || '(empty)';
                    sayer $break if $has-break && $seen;

                    if PairContext.ACCEPTS(@matches.head) {
                        my uint $last-linenr = @matches.head.key - 1;

                        if $group-matches {
                            sayer $source if $show-filename;
                            for @matches.map({ $_ if .value.elems }) {
                                my uint $linenr = .key;
                                breaker($linenr, $last-linenr);
                                if Slip.ACCEPTS(.value) {
                                    # Can only produce a Slip from a real
                                    # Callable, which cannot have any
                                    # highlighting, so don't bother
                                    sayer "$linenr:$_" for @(.value);
                                    ++$seen;
                                }
                                else {
                                    sayer $linenr ~ ':' ~ (.matched
                                      ?? line-post-proc stringify(.value)
                                      !! stringify(.value)
