gram_grep: grep for the 21st Century





4.00/5 (3 votes)
grep for the 21st century
Introduction
gram_grep
is a search tool that goes far beyond the capabilities of grep
. Searches can span multiple lines and may be chained together in a variety of ways and can even utilise bison style grammars.
Maybe you want a search to ignore comments, or search only within strings. Maybe you have code that has SQL within strings and that SQL itself contains strings that you want to search in. The possibilities are endless and there is no limit to the sequence of sub-searches.
For example, here is how you would search for the text memory_file
outside of C and C++ style comments:
gram_grep -vE "\/\/.*|\/\*(?s:.)*?\*\/" -F memory_file main.cpp
Switches
Because multiple searches can be pipelined, unlike grep
, it is always necessary to have a search specifier as well as the search pattern (e.g. -E "[A-Z_a-z]\w*"
rather than just the pattern alone: "[A-Z_a-z]\w*"
). This also means that search modifiers (e.g. --ignore-case
) only apply to the current search and must be repeated per sub-search as required. Note that as a side-effect of this strategy, search modifiers must appear before the search specifier in order to be picked up correctly (e.g. -iE "[A-Z_]\w*"
).
Because gram_grep
searches can span multiple lines, you can specify --display-whole-match
to show the entire match.
The intention is to eventually support all the switches as specified by grep
(or at least all that make any sense in this context).
A Note on DOS Prompt Escapes
The characters &
, <
, >
, ^
and |
have a special meaning to the DOS shell. When any of these characters are used outside of a string, they must be escaped by the ^
character.
For example if you wanted to pass the regexp [^0-9|\\[0-9]
, you would pass it as [^^0-9]^|\\[0-9]
.
If you wish to pass a double quote as part of a parameter then the entire parameter must be passed inside double quotes. In order for the double quote to be passed as a literal in this situation, it must be doubled up.
For example if you wanted to pass the regexp "/*"(?s:.)*?"*/"
, you would pass it as """/*""(?s:.)*?""*/"""
.
There is a switch --dump-argv
in order to clarify what gram_grep
actually receives should you end up completely baffled!
The Linux shell
Just use single quotes around your parameters. If you want to pass a single quote as part of your parameter, then terminate the string, escape the single quote, then restart the string.
e.g. for [^']
pass '[^'\'']'
Configuration Files Make Things Easier
It quickly gets tedious trying to correctly escape characters in a command shell, so we switch to a configuration file to also exclude strings:
gram_grep -f nosc.g main.cpp
The config file nosc.g
looks like this:
%%
%%
%%
'([^'\\\r\n]|\\.)*' skip()
\"([^"\\\r\n]|\\.)*\" skip()
R\"\((?s:.)*?\)\" skip()
"//".*|"/*"(?s:.)*?"*/" skip()
memory_file 1
%%
Note how characters are also skipped just in case there is a character containing a double quote! Also note how we have moved our search for memory_file
directly into the config file as this part of the config lists regexes that are passed to a lexer generator. This means that we specify the things we want to match (use 1
for the id in this case) or explicitly skip (use skip()
in this case) all within the same section. This mode alone has already given us far more searching power than with traditional techniques.
If we wanted to only search in strings or comments, we would use 1
instead of skip()
for those regexes and omit the memory_file
line altogether. We would then pass memory_file
with -F
as a command line parameter for example.
Source Control
Note that it is possible to issue a command to check out files from source control:
gram_grep -r -E "v4\.5\.1" --replace v4.5.2 -o --checkout "tf.exe checkout $1" *.csproj
The above example would replace v4.5.1
with v4.5.2
in *.csproj
, checking out the files from TFS as they match. Note that there are also switches --startup
and --shutdown
where you can run other commands at startup and exit respectively if required (e.g., "tf.exe workspace /new /collection:http://... refactor /noprompt"
and "tf.exe workspace /delete /collection:http://... refactor /noprompt"
).
The Configuration File Format
The config file has the following format:
<grammar/lexer directives>
%%
<grammar>
%%
<regexp macros>
%%
<regexes>
%%
As implied above, the grammar/lexer directives
, grammar
and regexp macros
are all optional.
Here is an example of a simple grammar that recognises C++ strings split over multiple lines (strings.g
):
/*
NOTE: in order to successfully find strings it is necessary to filter out comments and chars.
As a subtlety, comments could contain apostrophes (or even unbalanced double quotes in
an extreme case)!
*/
%token RawString String
%%
list: String { match = substr($1, 1, 1); };
list: RawString { match = substr($1, 3, 2); };
list: list String { match += substr($2, 1, 1); };
list: list RawString { match += substr($2, 3, 2); };
%%
%%
\"([^"\\\r\n]|\\.)*\" String
R\"\((?s:.)*?\)\" RawString
'([^'\\\r\n]|\\.)*' skip()
[ \t\r\n]+|"//".*|"/*"(?s:.)*?"*/" skip()
%%
Although the grammar is just about as simple as it gets, note the scripting added. Each string fragment is joined into a match
, that can then be searched on by a following search. This means we can search within C++ strings without worrying about how they are split over lines.
Note how we have switched from using 1
as the matching regexp id to names which we have specified using %token
and used in the grammar.
Example usage:
gram_grep -f sample_configs/strings.g -F grammar main.cpp
The full list of scripting commands are listed below. You can see their use in the more sophisticated examples that follow later. $n
, $from
and $to
refer to the item in the production you are interested in (numbering starts at 1
).
- Note that all strings can contain item (
$n
) specifiers. - All strings can be replaced by a function returning a string.
Functions Returning Strings
format('text', ...);
(use{}
for format specifiers)replace_all('text', 'regexp', 'text')
system('text');
General Functions
erase($n);
erase($from, $to);
erase($from.second, $to.first);
insert($n, 'text');
insert($n.second, 'text');
match = $n;
match = substr($n, <omit from left>, <omit from right>);
match += $n;
match += substr($n, <omit from left>, <omit from right>);
print('text');
replace($n, 'text');
replace($from, $to, 'text');
replace($from.second, $to.first, 'text');
replace_all($n, 'regexp', 'text');
--if Syntax
This is a standalone syntax that does not currently support any function nesting or $n
within the regexes. It has following format:
regex_search($n, 'regex')
{ || regex_search($n, 'regex')
}
Notes on Grammars
By default, the entire grammar will match. However, there are times you are only interested if specific parts of your grammar matches. If you want to only match on particular grammar rules, use {}
just before the terminating semi-colon for that rule. This technique is shown in a later example.
Most of the time, the only grammar/lexer directive you will care about will be %token
. However, the following are supported:
- %captures (parenthesis in grammars will be treated as captures)
- %consume (list tokens that are not to be reported as unused)
- %option caseless
- %token
- %left
- %right
- %nonassoc
- %precedence
- %start
- %x
Command Line Switches for gram_grep
-h, --help
Shows help-c, --checkout <cmd>
Checkout command (include $1 for pathname)--colour, --color
Use markers to highlight the matching strings--display-whole-match
Display a multiline match-D, --dot
Dump DFA regexp in DOT format-d, --dump
Dump DFA regexp--dump-argv
Dump command line arguments-x, --exclude <wildcard>
Exclude pathnames matching wildcard--extend-search
Extend the end of the next match to be the end of the current match-E, --extended-regexp <regexp>
Search using DFA regexp-f, --file <config file>
Search using config file-l, --files-with-matches
Output pathname only-F, --fixed-strings <text>
Search for plain text with support for capture ($n
) syntax-e, --force-write
If a file is read only, force it to be writable-t, --hits
Show hit count per file-i, --ignore-case
Case insensitive searching-I, --if <conditions>
Make searching conditional-v, --invert-match
Find all text that does not match-V, --invert-match-all
Only match if the search does not match at all-o, --perform-output
Output changes to matching file-P, --perl-regexp <regexp>
Search usingstd::regex
-p, --print <text>
Print text instead of line of match-r, -R, --recursive
Recurse subdirectories-a, --replace <text>
Replace matching text--return-previous-match
Return the previous match instead of the current one-S, -shutdown <cmd>
Command to run when exiting-s, -startup <cmd>
Command to run at startup-u, --utf8
In the absence of a BOM assume UTF-8-w, --whole-word
Force match to match only whole words--word-list <pathname>
Search for a word from the supplied word list-W, --writable
Only process files that are writable<pathname>
... Files to search (wildcards supported)
Unicode
If an input file has a BOM (byte order marker), then that will be recognised. In the case of UTF-16, the contents will be automatically converted to UTF-8 in memory to allow uniform processing.
Unicode support can be enabled with the --utf8
switch. Two things happen with this switch enabled:
- Any files without a BOM (byte order marker) are assumed to be UTF-8.
- The lexer enables Unicode support (
-E
,-vE
,-VE
,-f
,-vf
,-Vf
). Note that thestd::regex
support (-P
,-vP
,-VP
) does not currently support Unicode.
Examples
Searching for SQL INSERT Commands Without a Column List
insert.g
:
%token INSERT INTO Name String VALUES
%%
start: insert;
insert: INSERT into name VALUES;
into: INTO | %empty;
name: Name | Name '.' Name | Name '.' Name '.' Name;
%%
%%
(?i:INSERT) INSERT
(?i:INTO) INTO
(?i:VALUES) VALUES
\. '.'
(?i:[a-z_][a-z0-9@$#_]*|\[[a-z_][a-z0-9@$#_]*[ ]*\]) Name
'([^']|'')*' String
\s+|--.*|"/*"(?s:.)*?"*/" skip()
%%
The command line looks like this:
gram_grep -r -f sample_configs/insert.g *.sql
Searching for SQL MERGE Commands Without WITH(HOLDLOCK) Within Strings Only
First the string extraction (strings.g
):
%token RawString String
%%
list: String { match = substr($1, 1, 1); };
list: RawString { match = substr($1, 3, 2); };
list: list String { match += substr($2, 1, 1); };
list: list RawString { match += substr($2, 3, 2); };
%%
%%
\"([^"\\\r\n]|\\.)*\" String
R\"\((?s:.)*?\)\" RawString
'([^'\\\r\n]|\\.)*' skip()
[ \t\r\n]+|"//".*|"/*"(?s:.)*?"*/" skip()
%%
Or if we wanted to scan C#:
%token String VString
%%
list: String { match = substr($1, 1, 1); };
list: VString { match = substr($1, 2, 1); };
list: list '+' String { match += substr($3, 1, 1); };
list: list '+' VString { match += substr($3, 2, 1); };
%%
ws [ \t\r\n]+
%%
\+ '+'
\"([^"\\\r\n]|\\.)*\" String
@\"([^"]|\"\")*\" VString
'([^'\\\r\n]|\\.)*' skip()
{ws}|"//".*|"/*"(?s:.)*?"*/" skip()
%%
Now the grammar to search inside the strings (merge.g
):
%token AS Integer INTO MERGE Name PERCENT TOP USING
%%
merge: MERGE opt_top opt_into name opt_alias USING;
opt_top: %empty | TOP '(' Integer ')' opt_percent;
opt_percent: %empty | PERCENT;
opt_into: %empty | INTO;
name: Name | Name '.' Name | Name '.' Name '.' Name;
opt_alias: %empty | opt_as Name;
opt_as: %empty | AS;
%%
%%
(?i:AS) AS
(?i:INTO) INTO
(?i:MERGE) MERGE
(?i:PERCENT) PERCENT
(?i:TOP) TOP
(?i:USING) USING
\. '.'
\( '('
\) ')'
\d+ Integer
(?i:[a-z_][a-z0-9@$#_]*|\[[a-z_][a-z0-9@$#_]*[ ]*\]) Name
\s+ skip()
%%
The command line looks like this:
gram_grep -r -f sample_configs/strings.g -f sample_configs/merge.g *.cpp
Looking for Uninitialised Variables in Headers
Note the use of {}
here to specify that we only care when the rule item: Name;
matches.
%token Bool Char Name NULLPTR Number String Type %% start: decl; decl: Type list ';'; list: item | list ',' item; item: Name {}; item: Name '=' value; value: Bool | Char | Number | NULLPTR | String; %% NAME [_A-Za-z][_0-9A-Za-z]* %% = '=' , ',' ; ';' true|TRUE|false|FALSE Bool nullptr NULLPTR BOOL|BSTR|BYTE|COLORREF|D?WORD|DWORD_PTR Type DROPEFFECT|HACCEL|HANDLE|HBITMAP|HBRUSH Type HCRYPTHASH|HCRYPTKEY|HCRYPTPROV|HCURSOR|HDBC Type HICON|HINSTANCE|HMENU|HMODULE|HSTMT|HTREEITEM Type HWND|LPARAM|LPCTSTR|LPDEVMODE|POSITION|SDWORD Type SQLHANDLE|SQLINTEGER|SQLSMALLINT|UINT|U?INT_PTR Type UWORD|WPARAM Type bool|(unsigned\s+)?char|double|float Type (unsigned\s+)?int((32|64)_t)?|long|size_t Type {NAME}(\s*::\s*{NAME})*(\s*[*])+ Type {NAME} Name -?\d+(\.\d+)? Number '([^'\\\r\n]|\\.)*' Char \"([^"\\\r\n]|\\.)*\" String [ \t\r\n]+|"//".*|"/*"(?s:.)*?"*/" skip() %%
The command line looks like this:
gram_grep -r -f sample_configs/uninit.g *.h
Automatically Converting boost::format to std::format
Note the use of a variety of scripting commands:
%token Integer Name RawString String
%%
start: '(' format list ')' '.' 'str' '(' ')'
/* Erase the first "(" and the trailing ".str()" */
{ erase($1);
erase($5, $8); };
start: 'str' '(' format list ')'
/* Erase "str(" */
{ erase($1, $2); };
format: 'boost' '::' 'format' '(' string ')'
/* Replace "boost" with "std" */
/* Replace the format specifiers within the strings */
{ replace($1, 'std');
replace_all($5, '%(\d+[Xdsx])', '{:$1}');
replace_all($5, '%((?:\d+)?\.\d+f)', '{:$1}');
replace_all($5, '%x', '{:x}');
replace_all($5, '%[ds]', '{}');
replace_all($5, '%%', '%');
erase($6); };
string: String;
string: RawString;
string: string String;
string: string RawString;
list: %empty;
list: list '%' param
/* Replace "%" with ", " */
{ replace($2, ', '); };
param: Integer;
param: name
/* Replace any trailing ".c_str()" calls with "" */
{ replace_all($1, '\.c_str\(\)$', ''); };
name: Name opt_func
| name deref Name opt_func;
opt_func: %empty | '(' opt_param ')';
deref: '.' | '->' | '::';
opt_param: %empty | Integer | name;
%%
%%
\( '('
\) ')'
\. '.'
% '%'
:: '::'
-> '->'
boost 'boost'
format 'format'
str 'str'
-?\d+ Integer
\"([^"\\\r\n]|\\.)*\" String
R\"\((?s:.)*?\)\" RawString
'([^'\\\r\n]|\\.)*' skip()
[_a-zA-Z][_0-9a-zA-Z]* Name
\s+|"//".*|"/*"(?s:.)*?"*/" skip()
%%
The command line looks like this:
gram_grep -o -r -f format.g *.cpp
Coping With Nested Constructs Without Caring What They Are
This example finds an if
statement, its opening parenthesis and its closing parenthesis and copes with any parenthesis nested in between. We introduce the nonsense token anything
so that we stop matching directly after the closing parenthesis and we rely on lexer states to cope with the nesting.
Note the use of the %consume
directive to avoid a warning that token anything
is not used by the grammar.
%token if anything
%consume anything
%x PREBODY BODY PARENS
%%
start: if '(' ')';
%%
any (?s:.)
char '([^'\\\r\n]|\\.)+'
name [A-Z_a-z][0-9A-Z_a-z]*
string \"([^"\\\r\n]|\\.)*\"|R\"\((?s:.)*?\)\"
ws [ \t\r\n]+|"//".*|"/*"(?s:.)*?"*/"
%%
<INITIAL>if<PREBODY> if
<PREBODY>[(]<BODY> '('
<PREBODY>.{+}[\r\n]<.> skip()
<BODY,PARENS>[(]<>PARENS> skip()
<PARENS>[)]<<> skip()
<BODY>[)]<INITIAL> ')'
<BODY,PARENS>{string}<.> skip()
<BODY,PARENS>{char}<.> skip()
<BODY,PARENS>{ws}<.> skip()
<BODY,PARENS>{name}<.> skip()
<BODY,PARENS>{any}<.> skip()
{string} anything
{char} anything
{ws} anything
{name} anything
{any} anything
%%
Finding Unused Variables in C++ Functions
gram_grep -r *.cpp;*.h -f \configs\block.g --extend-search -f \configs\var.g -VT $1
block.g:
// Locate a top level braced block (i.e. function bodies) // Note that we filter out class, struct and namespace // in order to match any embeded blocks inside those constructs. %token Name anything %x BODY BRACES %% start: '{' '}'; %% any (?s:.) char '([^'\\]|\\.)+' name [A-Z_a-z][0-9A-Z_a-z]* string \"([^"\\]|\\.)*\"|R\"\((?s:.)*?\)\" ws [ \t\r\n]+|\/\/.*|"/*"(?s:.)*?"*/" %% (class|struct|namespace|union)\s+{name}?[^;{]*\{ skip() extern\s*["]C["]\s*\{ skip() <INITIAL>\{<BODY> '{' <BODY,BRACES>\{<>BRACES> skip() <BRACES>\}<<> skip() <BODY>\}<INITIAL> '}' <BRACES,BODY>{string}<.> skip() <BRACES,BODY>{char}<.> skip() <BRACES,BODY>{ws}<.> skip() <BRACES,BODY>{name}<.> skip() <BRACES,BODY>{any}<.> skip() {string} anything {char} anything {name} anything {ws} anything {any} anything %%
var.g:
%captures %token Name Keyword String Whitespace %% start: Name opt_template Whitespace (Name) opt_ws ';'; opt_template: %empty | '<' name '>'; name: Name | name '::' Name; opt_ws: %empty | Whitespace; %% name [A-Z_a-z]\w* %% ; ';' < '<' > '>' :: '::' #{name} Keyword break Keyword CExtDllState Keyword CShellManager Keyword CWaitCursor Keyword continue Keyword delete Keyword enum Keyword false Keyword goto Keyword namespace Keyword new Keyword return Keyword throw Keyword VTS_[0-9A-Z_]* Keyword {name} Name \"([^"\\\r\n]|\\.)*\" String R\"\((?s:.)*?\)\" String \s+ Whitespace \/\/.* skip() "/*"(?s:.)*?"*/" skip() %%
All of these example configs are available in the zip with a .g
extension.
Linux/g++
There is now a Makefile which will allow you to build on Linux and also a CMakeLists.txt
file if you prefer to go that route.
History
- 18/07/2017: Created
- 10/09/2017: Reworked so that searches can be pipelined
- 22/09/2017: Now finds all matches within a sub-search
- 24/09/2017: Added
-v
support - 26/09/2017: Fixed config files
- 20/10/2017: Now ignoring zero length files
- 21/10/2017: Fixed
{}
handling - 11/12/2017: Now supports C style comments in sections before regexp macros
- 16/12/2017: Slight fix to
parse()
inmain.cpp
and introducedlast_productions_
inparsertl/search.hpp
- 03/02/2018: Added match count and now outputting match with context
- 21/03/2018: Updated
lexertl
andparsertl
libraries - 14/04/2018: Added
-V
support - 26/04/2018: Fixed out of bounds checking for
substr()
- 17/05/2018: Took filesystem out of experimental (needs g++ 8.1 or latest VC++)
- 23/09/2018: Now supporting EBNF syntax
- 23/09/2018: Updated
parsertl
- 06/10/2018: Updated
parsertl
- 02/03/2019: Added checkout/replacement ability
- 28/08/2019: Added modifying actions for grammars
- 01/09/2019: Added
replace_all()
- 07/09/2019: Added
boost::format
tostd::format
conversion example - 22/09/2019: Added
-exclude
switch - 04/10/2019: Added warnings for unused tokens
- 05/10/2019: Fixed line number reporting for unknown token names
- 17/01/2020: Updated
lexertl
- 20/01/2020: Updated
lexertl
- 23/03/2020: Added negated wildcard support (as per VS 2019 16.5) and now loading/saving UTF16 correctly
- 24/03/2020: Fixed
-exclude
logic - 02/05/2020: Added lexer state support
- 03/05/2020: Now checking all lexer states for missing ids before issuing warnings
- 04/05/2020: Added
-writable
flag - 28/06/2020: Reworked article text
- 28/06/2020: Added lexer states example
- 28/06/2020: Updated zip with config files.
- 30/06/2020:
RawString
can be multi-line, addedRawString
support toif.g
- 01/07/2020: Updated samples in zip again
- 03/07/2020: Added Source Control section to article
- 04/07/2020: Tweaks to switch explanations in article and help text in zip
- 04/07/2020: Added example startup and shutdown strings to article
- 16/07/2020: Added SQL INSERT check example
- 29/07/2020:
-exclude
,-f
,-vf
and-Vf
can now take a semi-colon separated list of wildcards instead of just one - 24/08/2020: Wildcards are now case sensitive for non-windows builds
- 25/08/2020: Corrected negation character in wildcard character sets
- 07/09/2020: Pathnames can now be semicolon separated
- 20/12/2020: Added Unicode support with
-utf8
switch - 20/12/2020: Fixed bug where if a literal filename was passed with no path then it was not loaded
- 30/01/2021: Fixed literal filename bug for recursive mode
- 12/05/2021: Added
-l
switch and brought libraries up to date - 30/05/2021: Added regex capture support for
-replace
($0
-$9
) - 03/06/2021: Added
$0
capture support for-E
switch (also fixed usage of-replace
with-v
switches). - 01/07/2021: Updated
parsertl
(now consumes less memory). - 03/07/2021: Now supporting
stdin
so that input can be piped in. - 08/07/2021: Added error msg when combining
stdin
and-o
. - 08/07/2021: Removed unnecessary newlines from strings.
- 04/08/2021: Added
-force_write
switch. - 04/08/2021:
-r
is now order independent as it always should have been. - 06/08/2021: Fix to
-l
processing. - 07/08/2021: Added
%option caseless
directive. - 03/09/2021: Fixed
-Wall
warnings. - 01/11/2021: Updated article text.
- 07/11/2021: More wildcard processing fixes.
- 17/12/2021: Fix to
boost::format()
script. - 14/03/2022: When in
cin
mode, don't ouput the line number in matches. - 17/03/2022: Fix to end marker when doing a negated grammar search.
- 21/01/2023: Added
%captures
support. - 30/01/2023: Pass iterators by reference in
parsertl::search()
. - 31/01/2023: Fixed lexer iterator guards in
parsertl
functions. - 27/02/2023: Upgraded to Unicode 15.1.0, added
-hits
switch. - 23/05/2023: Added
skip_permission_denied
to directory iterators. - 30/07/2023: Enabled
%prec
support and added C style comment support to regex macros section (must occur before the macro name). - 05/08/2023: Added new regex rule, corrected Charset regex.
- 08/08/2023: Added missing backslashes to more regexes.
- 09/08/2023: Added more missing backslashes to regexes.
- 25/08/2023: Bug fix to lexertl regex macro handling of BOL and EOL, parsertl sm construction speedup.
- 09/11/2023: Added new switches
-Ee
,-fe
,-Pe
,-T
,-vT
and-VT
. - 10/11/2023: Corrected flags for
-T
. - 12/11/2023: Added basic
-i
support for-T
switches. - 18/11/2023:
-T
,-vT
,-VT
searches now do automatic whole word matching as appropriate. - 12/12/2023: Updated searching in strings example as includes now use angle brackets.
- 27/12/2023: Split source into multiple files.
- 15/02/2024: Updated to use lexertl17 and parsertl17.
- 13/03/2024: Fixed grammar for \x handling in strings and characters. Thanks mingodad@github!
- 06/04/2024: Updated comments.g to exclude C++ strings.
- 19/04/2024: Corrected usage of negated wildcards.
- 02/05/2024: Now converting pathnames to utf-8 to avoid unicode issues and added try/catch around the
process_file()
implementation to avoid bailing ifstd::filesystem
throws. Introduced a regex macro{macro_name}
. - 03/05/2024: Added another
add_pathname()
fix. - 04/05/2024: Cleaned up lexer.
- 12/05/2024: Added
print
support. - 27/06/2024: Upgraded to lexertl17 and parsertl17. No need for
;
after code blocks now. - 28/06/2024:
;
is back to terminate a block, but|
can be used with blocks now. - 06/07/2024: Reworked switches handling.
- 07/07/2024: Added more long form switches.
- 14/07/2024: Bug fixes for long form switch comparison.
- 25/07/2024: Whole word only and wildcard fix.
- 13/09/2024: Added switches
--dot
and--colour
. - 21/09/2024: Added switches
--extend-search
and--whole-word
. Added scripting commandsexec()
andformat()
. - 22/09/2024: Added a
replace_all()
overload taking and returning a string. - 27/09/2024: Fixed AST usage.
- 29/09/2024: Added switch
--display-whole-match
. - 06/10/2024: Added switch
--if
. - 12/10/2024: Added switch
--word-list
. - 13/10/2024: Corrected some examples and clarified DOS Prompt Escapes section.
- 16/10/2024: Separated out switches
-v
and-V
from the search types.