Search by

rmb32 / kevin

rogerbarnfather

An easy bulk filesystem manipulation utility for your PHP files — find files, rename files, replace in filenames, replace in file content, move files and directories, and correct namespaces to conform to PSR-4, all from the command line.

Package info

bitbucket.org/rmb32/kevin

pkg:composer/rmb32/kevin

Statistics

Installs: 3

Dependents: 1

Suggesters: 0

v1.0.0 2026-10-07 15:01 UTC

This package is auto-updated.

Last update: 2026-10-07 15:01:51 UTC


README

kevin is a command line utility that lets you easily manage common filesystem tasks in bulk for PHP files only. Any files without the .php extension are ignored. Hidden files are ignored. Symlinks are ignored.

It can find files, rename files, replace in filenames, replace in file content, move files and directories, and correct namespaces to conform to the PSR-4 standard — including closing the "bare reference" gap left behind when a namespace reorganisation splits one shared namespace into several.

The full, normative specification — including a worked example that every command must match byte-for-byte — lives in who-is-kevin.md.

Install

As a dev dependency of a Composer project:

composer require --dev rmb32/kevin

This installs vendor/bin/kevin.

Alternatively, download a standalone kevin.phar build (see Building a phar below) and run it directly with php kevin.phar ... — no Composer install needed in the target project at all.

The record format

Output from kevin is of a consistent format so the pipe operator can be used to chain kevin commands, or pipe into other commands like awk. Every command emits one record per line, exactly five tab-separated fields, with no header line, no quoting, and no surrounding whitespace:

  1. the full filepath relative to the scan root
  2. the PHP file's namespace (empty if the file declares none)
  3. the line number of the namespace declaration (empty if the file declares none)
  4. the folder part of the filepath (empty if the file is directly in the root)
  5. the filename only of the file, including the .php extension

Paths always use / as the directory separator regardless of platform.

Piping

Any command that operates on files accepts either a scan root (positional argument, default .) or, when its standard input is piped, a stream of records from a previous kevin command:

  • If stdin is not a terminal, the command ignores its positional scan root for selection and instead reads records from stdin, one per line. The positional root, if given, still serves as the base for resolving the relative filepaths carried by those records.
  • If stdin is a terminal, the command scans its positional root as normal.
  • An empty piped stream means the command is a no-op: it does nothing, writes nothing to stdout and exits successfully. It does say so on stderr, with a pointer to --scan, because a script or agent whose stdin is not a terminal lands here without piping anything. Pass --scan there to scan the root.

Atomicity

All operations are atomic, with a strict compute-then-act policy: everything that needs to be done is computed in full before anything on disk is changed. If any problem is detected — a computed rename whose destination already exists, two inputs whose destinations coincide, a file that cannot be read or written, a piped record referencing a missing file — the entire operation aborts, changing nothing, and reports an error to stderr before exiting 1. There is deliberately no --dry-run and no interactive confirmation: the atomicity guarantee is the safety model. --explain (on standardise:namespace and move:path) isn't a dry-run mode either — it's the same fully computed, fully validated plan a real run would apply, printed instead of applied; nothing is ever written when it's given.

Commands

kevin list:files [DIR]
kevin add:prefix STR [DIR]
kevin add:suffix STR [DIR]
kevin replace:name FROM TO [DIR]
kevin replace:content FROM TO [DIR]
kevin report:namespace PREFIX --relative-to=DIR [DIR]
kevin standardise:namespace [DIR] --with=PREFIX --rectify=DIR [--rectify=DIR ...] --relative-to=DIR [--explain]
kevin move:path FROM TO --with=PREFIX --rectify=DIR [--rectify=DIR ...] --relative-to=DIR [--explain]

Every command that takes [DIR] also accepts --scan. By default kevin scans DIR only when stdin is a terminal; otherwise it reads records from stdin, and an empty stdin is a silent no-op. Callers with no terminal (scripts, CI, agents) should pass --scan to force a directory scan.

list:files

Recursively scans DIR (default .) and emits one record per PHP file, containing its declared namespace and the line number of that declaration.

add:prefix / add:suffix

Rename every file's filename by prepending appended/appending STR to it, always before the extension (file1.php + prefix Start → Startfile1.php; file1.php + suffix End → file1End.php). A computed rename that would collide with an existing file aborts the whole operation.

replace:name

Renames every file's filename (its portion before the extension) by replacing literal occurrences of FROM with TO — a plain substring replacement, no regex, every occurrence (file1.php with il → ABC → fABCe1.php). A file whose name contains no occurrence is unchanged but still emitted.

replace:content

Rewrites the content of every file, replacing every literal occurrence of FROM with TO throughout the entire file body. The record for each file is emitted unchanged — namespace and line are unaffected by content replacement.

Because the record stream is a selection, every scanned file is emitted whether or not it matched. So a FROM that matches nothing looks exactly like a successful run on stdout — and kevin says so on stderr instead: Nothing matched: 0 of 12 files changed. A run that did change something stays silent, so a pipeline is unaffected either way. Both replace:name and replace:content do this.

report:namespace

Emits one record per file that declares a namespace matching PREFIX by the whole-segment rule, and is not PSR-4 compliant. --relative-to=DIR (always required) is the directory against which compliance is checked.

A file's expected namespace, under base PREFIX and --relative-to DIR, is PREFIX followed by the file's folder path relative to DIR, each directory becoming one namespace segment — the filename itself never contributes a segment. So src/App/Code/file1.php relative to --relative-to=src expects Project\Cool\App\Code. Only non-compliant files are reported, and they are reported with their declared namespace.

standardise:namespace

Operates on everything it is given — every file it scans under DIR, or every record it receives on stdin — with no filtering by old namespace. For each file:

  • its new namespace is --with=PREFIX followed by the file's folder path relative to --relative-to, each directory becoming one namespace segment; the file's declared namespace remainder (if any) is discarded entirely, and the whole declaration is replaced in place on its existing line, keeping the line number;
  • then every PHP file under --rectify=DIR (recursively) has its references updated, type by type: a reference Old\Namespace\Type is rewritten only when Type is declared by a processed file that moved out of Old\Namespace (case-insensitively, as PHP resolves classes). The match is on the exact namespace, never a prefix, and a namespace statement is never touched, so moving one file leaves its siblings, the files in its sub-namespaces, and references to types that stayed behind exactly as they were. use statements, fully qualified names and docblock references are all updated. A group import (use Old\NS\{A, B as C};) with a moved member is split into one use per member; a namespace alias (use Old\NS;, use Old\NS as X;) follows the namespace — including an alias to a parent whose sub-namespaces moved, so Alias\Sub\Type keeps resolving — only when everything at or under it moved by the same shift and nothing at or under it stayed behind (checked against the --rectify directories); otherwise it is left as written.

--with, --rectify (repeatable — give it more than once to cover several directories, e.g. src/ and tests/, in one pass without a whole-repo sweep that would also walk vendor/; a second invocation against an already-corrected file would find nothing left to rectify, which is why this is a repeatable flag rather than something left to the shell to loop) and --relative-to are always required. Every processed file is emitted with its new namespace; files under --rectify that are not among the processed set are updated but not emitted.

Also closes the bare-reference gap: when the run splits files that used to share one flat namespace into different new namespaces, a bare reference (new X, X::..., instanceof X, extends/implements, a property or promoted-parameter type, a parameter/return type-hint) from one to a type declared in another gets a new use import inserted automatically, rather than silently pointing at a class that no longer resolves. A name already bound by an existing use/alias is left alone. The same import is added to a moved file for each bare reference to a type that stayed behind in its old namespace (found by scanning the --rectify directories), which it used to resolve for free by sharing that namespace.

--explain prints the computed, validated plan instead of applying it — nothing is written.

move:path

kevin move:path FROM TO --with=PREFIX --rectify=DIR [--rectify=DIR ...] --relative-to=DIR [--explain]

Moves FROM (a file or directory) to TO, then runs the exact standardise:namespace logic above against the moved files at their new location — the mv + standardise:namespace sequence this replaces, combined into one atomic step. TO must not already exist; its parent directory must already exist (kevin never creates directories). Everything is computed and validated against the tree as it stood before the move; only the move itself and the already-validated writes happen at apply time, in that order. --with, --rectify (repeatable, same as standardise:namespace) and --relative-to are always required. --explain behaves as above, with one extra leading would move line.

Matching rules

Where a command matches a namespace prefix, matching is by whole namespace segments: Project\Cool\Abc matches; Project\Cooler\Abc does not.

Exit codes

0 on success (including a no-op pipe). 1 if the operation aborts because of a problem, or if a required flag/argument is missing or malformed. Problems are reported to stderr before exiting.

Examples

See who-is-kevin.md for the full worked example. The intended workflow — select the files that violate PSR-4, then correct exactly those while updating every reference to the old namespaces:

kevin list:files | kevin report:namespace "Project\Cool" --relative-to=. \
  | kevin standardise:namespace --with="Super\Duper" --rectify=. --relative-to=.

Building a phar

composer install
composer build-phar

Produces a standalone kevin.phar (via humbug/box) that runs with just php kevin.phar ... — no Composer install needed in the target project.

Tests

The acceptance contract is who-is-kevin.md's example blocks, asserted byte-for-byte through real subprocess runs of bin/kevin against real scratch directories (tests/KevinBinaryE2ETest.php, plus tests/MovePathE2ETest.php and tests/BareReferenceRectificationE2ETest.php for move:path and the bare-reference gap respectively). tests/AcceptanceContractsTest.php pins the parts the examples don't show — empty fields, exit codes, collision and missing-record aborts, the empty-pipe no-op, and hidden/symlink/non-PHP skipping. The same acceptance suite can be pointed at the built phar via the KEVIN_BINARY environment variable. Smaller unit tests cover the domain classes (RecordFormat, NamespaceParser, NamespaceMatcher, Psr4, NamespaceDeclarationRewriter, ReferenceRectifier, PhpScanner, TypeNameParser, BareReferenceInserter, AbsolutePath).

License

Proprietary. See LICENSE. Copyright (c) Roger Barnfather.