From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from localhost (localhost [127.0.0.1]) by arlo.cworth.org (Postfix) with ESMTP id 6FF5A6DE0C72 for ; Wed, 25 Jan 2017 11:40:29 -0800 (PST) X-Virus-Scanned: Debian amavisd-new at cworth.org X-Spam-Flag: NO X-Spam-Score: 0.509 X-Spam-Level: X-Spam-Status: No, score=0.509 tagged_above=-999 required=5 tests=[AWL=-0.143, SPF_NEUTRAL=0.652] autolearn=disabled Received: from arlo.cworth.org ([127.0.0.1]) by localhost (arlo.cworth.org [127.0.0.1]) (amavisd-new, port 10024) with ESMTP id I4ZGI3LIXCIg for ; Wed, 25 Jan 2017 11:40:27 -0800 (PST) Received: from guru.guru-group.fi (guru.guru-group.fi [46.183.73.34]) by arlo.cworth.org (Postfix) with ESMTP id 03EE26DE0C71 for ; Wed, 25 Jan 2017 11:40:26 -0800 (PST) Received: from guru.guru-group.fi (localhost [IPv6:::1]) by guru.guru-group.fi (Postfix) with ESMTP id 81025100063; Wed, 25 Jan 2017 21:40:09 +0200 (EET) From: Tomi Ollila To: David Bremner , notmuch@notmuchmail.org Subject: Re: [Patch v4] lib: regexp matching in 'subject' and 'from' In-Reply-To: <20170121135917.22062-1-david@tethera.net> References: <20170121032752.6788-1-david@tethera.net> <20170121135917.22062-1-david@tethera.net> User-Agent: Notmuch/0.23.3+85~g2b85e66 (https://notmuchmail.org) Emacs/24.5.1 (x86_64-unknown-linux-gnu) X-Face: HhBM'cA~ MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: quoted-printable X-BeenThere: notmuch@notmuchmail.org X-Mailman-Version: 2.1.22 Precedence: list List-Id: "Use and development of the notmuch mail system." List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Wed, 25 Jan 2017 19:40:29 -0000 On Sat, Jan 21 2017, David Bremner wrote: > the idea is that you can run > > % notmuch search subject:// > % notmuch search from:// I like this interface. > > or > > % notmuch search subject:"your usual phrase search" > % notmuch search from:"usual phrase search" > > This should also work with bindings, since it extends the query parser. > > This is trivial to extend for other value slots, but currently the only > value slots are date, message_id, from, subject, and last_mod. Date is > already searchable, and message_id is not obviously useful to regex > match. Why would not mesasge_id not be useful to regex match. I can come up quite a few use cases... but if there are techinal difficulties... then that should be mentioned instead. maybe this commit message should inform that xapian with field processors (1.4.x) is required for this feature -- and emphasize it a bit better in manual page ? Probably '//' is used to escape '/' -- should such a character ever needed in regex search. > > This was originally written by Austin Clements, and ported to Xapian > field processors (from Austin's custom query parser) by yours truly. > --- > > This version impliments the use of // to delimit regular expressions. > I have not tested the code paths with old (pre field processor) xapian. Fedora 25 has 1.2.24 -- T630 tests are skipped. It looks like these changes did not increase the failure count there. Some (mostly whitespace nitpicking) comments below: > > doc/man7/notmuch-search-terms.rst | 27 +++++++- > lib/Makefile.local | 1 + > lib/database-private.h | 2 + > lib/database.cc | 29 +++++++- > lib/regexp-fields.cc | 142 ++++++++++++++++++++++++++++++++= ++++++ > lib/regexp-fields.h | 77 +++++++++++++++++++++ > test/T630-regexp-query.sh | 82 ++++++++++++++++++++++ > 7 files changed, 354 insertions(+), 6 deletions(-) > create mode 100644 lib/regexp-fields.cc > create mode 100644 lib/regexp-fields.h > create mode 100755 test/T630-regexp-query.sh > > diff --git a/doc/man7/notmuch-search-terms.rst b/doc/man7/notmuch-search-= terms.rst > index de93d733..d8527e18 100644 > --- a/doc/man7/notmuch-search-terms.rst > +++ b/doc/man7/notmuch-search-terms.rst > @@ -34,10 +34,14 @@ indicate user-supplied values): >=20=20 > - from: >=20=20 > +- from:// > + > - to: >=20=20 > - subject: >=20=20 > +- subject:// > + > - attachment: >=20=20 > - mimetype: > @@ -71,6 +75,17 @@ subject of an email. Searching for a phrase in the sub= ject is supported > by including quotation marks around the phrase, immediately following > **subject:**. >=20=20 > +The **from:** and **subject** prefix can be also used to restrict the > +results to those whose from/subject value matches a regular > +expression (see **regex(7)**) delimited with //. > + > +:: > + > + notmuch search 'from:/bob@.*[.]example[.]com/' > + > +Regular expression searches are only available if notmuch is built > +with **Xapian Field Processors** (see below). And the poor user stopped reading far before this line, desperately trying the regex searches... >;/ so IMO this requirement should be notified earlie= r. > + > The **attachment:** prefix can be used to search for specific filenames > (or extensions) of attachments to email messages. >=20=20 > @@ -220,13 +235,18 @@ Boolean and Probabilistic Prefixes > ---------------------------------- >=20=20 > Xapian (and hence notmuch) prefixes are either **boolean**, supporting > -exact matches like "tag:inbox" or **probabilistic**, supporting a more = flexible **term** based searching. The prefixes currently supported by notm= uch are as follows. > - > +exact matches like "tag:inbox" or **probabilistic**, supporting a more > +flexible **term** based searching. Certain **special** prefixes are > +processed by notmuch in a way not stricly fitting either of Xapian's > +built in styles. The prefixes currently supported by notmuch are as > +follows. >=20=20 > Boolean > **tag:**, **id:**, **thread:**, **folder:**, **path:**, **property:** > Probabilistic > - **from:**, **to:**, **subject:**, **attachment:**, **mimetype:** > + **to:**, **attachment:**, **mimetype:** > +Special > + **from:**, **query:**, **subject:** >=20=20 > Terms and phrases > ----------------- > @@ -396,6 +416,7 @@ Currently the following features require field proces= sor support: >=20=20 > - non-range date queries, e.g. "date:today" > - named queries e.g. "query:my_special_query" > +- regular expression searches, e.g. "subject:/^\\[SPAM\\]/" >=20=20 > SEE ALSO > =3D=3D=3D=3D=3D=3D=3D=3D > diff --git a/lib/Makefile.local b/lib/Makefile.local > index b77e5780..ff812b5f 100644 > --- a/lib/Makefile.local > +++ b/lib/Makefile.local > @@ -52,6 +52,7 @@ libnotmuch_cxx_srcs =3D \ > $(dir)/query.cc \ > $(dir)/query-fp.cc \ > $(dir)/config.cc \ > + $(dir)/regexp-fields.cc \ Space instead of TAB above -- tab is used more often (and \:s usually align= ed) > $(dir)/thread.cc >=20=20 > libnotmuch_modules :=3D $(libnotmuch_c_srcs:.c=3D.o) $(libnotmuch_cxx_sr= cs:.cc=3D.o) > diff --git a/lib/database-private.h b/lib/database-private.h > index ccc1e9a1..9f5659a9 100644 > --- a/lib/database-private.h > +++ b/lib/database-private.h > @@ -190,6 +190,8 @@ struct _notmuch_database { > #if HAVE_XAPIAN_FIELD_PROCESSOR > Xapian::FieldProcessor *date_field_processor; > Xapian::FieldProcessor *query_field_processor; > + Xapian::FieldProcessor *from_field_processor; > + Xapian::FieldProcessor *subject_field_processor; > #endif > Xapian::ValueRangeProcessor *last_mod_range_processor; > }; > diff --git a/lib/database.cc b/lib/database.cc > index 2d19f20c..8a9ad251 100644 > --- a/lib/database.cc > +++ b/lib/database.cc > @@ -21,6 +21,7 @@ > #include "database-private.h" > #include "parse-time-vrp.h" > #include "query-fp.h" > +#include "regexp-fields.h" > #include "string-util.h" >=20=20 > #include > @@ -272,12 +273,16 @@ static prefix_t BOOLEAN_PREFIX_EXTERNAL[] =3D { > { "folder", "XFOLDER:" }, > }; >=20=20 > -static prefix_t PROBABILISTIC_PREFIX[]=3D { > +static prefix_t REGEX_PREFIX[]=3D { > { "from", "XFROM" }, > + { "subject", "XSUBJECT"}, > +}; > + > +static prefix_t PROBABILISTIC_PREFIX[]=3D { > + empty line ^ > { "to", "XTO" }, > { "attachment", "XATTACHMENT" }, > { "mimetype", "XMIMETYPE"}, > - { "subject", "XSUBJECT"}, > }; >=20=20 > const char * > @@ -295,6 +300,11 @@ _find_prefix (const char *name) > return BOOLEAN_PREFIX_EXTERNAL[i].prefix; > } >=20=20 > + for (i =3D 0; i < ARRAY_SIZE (REGEX_PREFIX); i++) { > + if (strcmp (name, REGEX_PREFIX[i].name) =3D=3D 0) > + return REGEX_PREFIX[i].prefix; > + } > + > for (i =3D 0; i < ARRAY_SIZE (PROBABILISTIC_PREFIX); i++) { > if (strcmp (name, PROBABILISTIC_PREFIX[i].name) =3D=3D 0) > return PROBABILISTIC_PREFIX[i].prefix; > @@ -1042,6 +1052,10 @@ notmuch_database_open_verbose (const char *path, > notmuch->query_parser->add_boolean_prefix("date", notmuch->date_field_p= rocessor); > notmuch->query_field_processor =3D new QueryFieldProcessor (*notmuch->q= uery_parser, notmuch); > notmuch->query_parser->add_boolean_prefix("query", notmuch->query_field= _processor); > + notmuch->from_field_processor =3D new RegexpFieldProcessor ("from", *no= tmuch->query_parser, notmuch); > + notmuch->subject_field_processor =3D new RegexpFieldProcessor ("subject= ", *notmuch->query_parser, notmuch); > + notmuch->query_parser->add_boolean_prefix("from", notmuch->from_field_p= rocessor); > + notmuch->query_parser->add_boolean_prefix("subject", notmuch->subject_f= ield_processor); > #endif > notmuch->last_mod_range_processor =3D new Xapian::NumberValueRangeProce= ssor (NOTMUCH_VALUE_LAST_MOD, "lastmod:"); >=20=20 > @@ -1058,7 +1072,12 @@ notmuch_database_open_verbose (const char *path, > notmuch->query_parser->add_boolean_prefix (prefix->name, > prefix->prefix); > } > - > +#if !HAVE_XAPIAN_FIELD_PROCESSOR > + for (i =3D 0; i < ARRAY_SIZE (REGEX_PREFIX); i++) { > + prefix_t *prefix =3D ®EX_PREFIX[i]; > + notmuch->query_parser->add_prefix (prefix->name, prefix->prefix); > + } > +#endif > for (i =3D 0; i < ARRAY_SIZE (PROBABILISTIC_PREFIX); i++) { > prefix_t *prefix =3D &PROBABILISTIC_PREFIX[i]; > notmuch->query_parser->add_prefix (prefix->name, prefix->prefix); > @@ -1138,6 +1157,10 @@ notmuch_database_close (notmuch_database_t *notmuc= h) > notmuch->date_field_processor =3D NULL; > delete notmuch->query_field_processor; > notmuch->query_field_processor =3D NULL; > + delete notmuch->from_field_processor; > + notmuch->from_field_processor =3D NULL; > + delete notmuch->subject_field_processor; > + notmuch->subject_field_processor =3D NULL; > #endif >=20=20 > return status; > diff --git a/lib/regexp-fields.cc b/lib/regexp-fields.cc > new file mode 100644 > index 00000000..8cb1cada > --- /dev/null > +++ b/lib/regexp-fields.cc > @@ -0,0 +1,142 @@ > +/* regexp-fields.cc - field processor glue for regex supporting fields > + * > + * This file is part of notmuch. > + * > + * Copyright =C2=A9 2015 Austin Clements > + * Copyright =C2=A9 2016 David Bremner > + * > + * This program is free software: you can redistribute it and/or modify > + * it under the terms of the GNU General Public License as published by > + * the Free Software Foundation, either version 3 of the License, or > + * (at your option) any later version. > + * > + * This program is distributed in the hope that it will be useful, > + * but WITHOUT ANY WARRANTY; without even the implied warranty of > + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the > + * GNU General Public License for more details. > + * > + * You should have received a copy of the GNU General Public License > + * along with this program. If not, see https://www.gnu.org/licenses/ . > + * > + * Author: Austin Clements > + * David Bremner > + */ > + > +#include "regexp-fields.h" > +#include "notmuch-private.h" > +#include "database-private.h" > +#include > + > +#if HAVE_XAPIAN_FIELD_PROCESSOR > +static void > +compile_regex (regex_t ®exp, const char *str) > +{ > + int err =3D regcomp (®exp, str, REG_EXTENDED | REG_NOSUB); > + > + if (err !=3D 0) { > + size_t len =3D regerror (err, ®exp, NULL, 0); > + char *buffer =3D new char[len]; > + std::string msg; > + (void) regerror (err, ®exp, buffer, len); > + msg.assign (buffer, len); > + delete buffer; > + > + throw Xapian::QueryParserError (msg); > + empty line ^ > + } > +} > + > +RegexpPostingSource::RegexpPostingSource (Xapian::valueno slot, const st= d::string ®exp) > + : slot_ (slot) > +{ > + ditto > + compile_regex (regexp_, regexp.c_str ()); > +} > + > +RegexpPostingSource::~RegexpPostingSource () > +{ > + regfree (®exp_); > +} > + > +void > +RegexpPostingSource::init (const Xapian::Database &db) > +{ > + db_ =3D db; > + it_ =3D db_.valuestream_begin (slot_); > + end_ =3D db.valuestream_end (slot_); > + started_ =3D false; > +} > + > +Xapian::doccount > +RegexpPostingSource::get_termfreq_min () const > +{ > + return 0; > +} > + > +Xapian::doccount > +RegexpPostingSource::get_termfreq_est () const > +{ > + return get_termfreq_max () / 2; > +} > + > +Xapian::doccount > +RegexpPostingSource::get_termfreq_max () const > +{ > + return db_.get_value_freq (slot_); > +} > + > +Xapian::docid > +RegexpPostingSource::get_docid () const > +{ > + return it_.get_docid (); > +} > + > +bool > +RegexpPostingSource::at_end () const > +{ > + return it_ =3D=3D end_; > +} > + > +void > +RegexpPostingSource::next (unused (double min_wt)) > +{ > + if (started_ && ! at_end ()) > + ++it_; > + started_ =3D true; > + > + for (; ! at_end (); ++it_) { > + std::string value =3D *it_; > + if (regexec (®exp_, value.c_str (), 0, NULL, 0) =3D=3D 0) > + break; > + } > +} > + > +static inline Xapian::valueno _find_slot (std::string prefix) > +{ > + if (prefix =3D=3D "from") > + return NOTMUCH_VALUE_FROM; > + else if (prefix =3D=3D "subject") > + return NOTMUCH_VALUE_SUBJECT; > + else > + throw Xapian::QueryParserError ("unsupported regexp field '" + prefix += "'"); > +} > + > +RegexpFieldProcessor::RegexpFieldProcessor (std::string prefix, Xapian::= QueryParser &parser_, notmuch_database_t *notmuch_) > + : slot(_find_slot (prefix)), term_prefix(_find_prefix (prefix.c_str ())= ), parser(parser_), notmuch(notmuch_) > +{ > +}; > + > +Xapian::Query > +RegexpFieldProcessor::operator() (const std::string & str) > +{ > + if (str.at (0) =3D=3D '/' && str.at (str.size () - 1)){ > + RegexpPostingSource *postings =3D new RegexpPostingSource (slot, str.su= bstr(1,str.size () - 2)); > + return Xapian::Query (postings->release ()); > + } else { > + /* TODO replace this with a nicer API level triggering of > + * phrase parsing, when possible */ > + std::string quoted=3D'"' + str + '"'; > + return parser.parse_query (quoted, NOTMUCH_QUERY_PARSER_FLAGS, term_pre= fix); > + } > +} > +#endif > diff --git a/lib/regexp-fields.h b/lib/regexp-fields.h > new file mode 100644 > index 00000000..bac11999 > --- /dev/null > +++ b/lib/regexp-fields.h > @@ -0,0 +1,77 @@ > +/* regex-fields.h - xapian glue for semi-bruteforce regexp search > + * > + * This file is part of notmuch. > + * > + * Copyright =C2=A9 2015 Austin Clements > + * Copyright =C2=A9 2016 David Bremner > + * > + * This program is free software: you can redistribute it and/or modify > + * it under the terms of the GNU General Public License as published by > + * the Free Software Foundation, either version 3 of the License, or > + * (at your option) any later version. > + * > + * This program is distributed in the hope that it will be useful, > + * but WITHOUT ANY WARRANTY; without even the implied warranty of > + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the > + * GNU General Public License for more details. > + * > + * You should have received a copy of the GNU General Public License > + * along with this program. If not, see https://www.gnu.org/licenses/ . > + * > + * Author: Austin Clements > + * David Bremner > + */ > + > +#ifndef NOTMUCH_REGEXP_FIELDS_H > +#define NOTMUCH_REGEXP_FIELDS_H > +#if HAVE_XAPIAN_FIELD_PROCESSOR > +#include > +#include > +#include "database-private.h" > +#include "notmuch-private.h" > + > +/* A posting source that returns documents where a value matches a > + * regexp. > + */ > +class RegexpPostingSource : public Xapian::PostingSource > +{ > + protected: > + const Xapian::valueno slot_; > + regex_t regexp_; > + Xapian::Database db_; > + bool started_; > + Xapian::ValueIterator it_, end_; > + > +/* No copying */ > + RegexpPostingSource (const RegexpPostingSource &); > + RegexpPostingSource &operator=3D (const RegexpPostingSource &); > + > + public: > + RegexpPostingSource (Xapian::valueno slot, const std::string ®exp= ); > + ~RegexpPostingSource (); > + void init (const Xapian::Database &db); > + Xapian::doccount get_termfreq_min () const; > + Xapian::doccount get_termfreq_est () const; > + Xapian::doccount get_termfreq_max () const; > + Xapian::docid get_docid () const; > + bool at_end () const; > + void next (unused (double min_wt)); > +}; > + > + > +class RegexpFieldProcessor : public Xapian::FieldProcessor { > + protected: > + Xapian::valueno slot; > + std::string term_prefix; > + Xapian::QueryParser &parser; > + notmuch_database_t *notmuch; > + > + public: > + RegexpFieldProcessor (std::string prefix, Xapian::QueryParser &parse= r_, notmuch_database_t *notmuch_); > + > + ~RegexpFieldProcessor () { }; > + > + Xapian::Query operator()(const std::string & str); > +}; > +#endif > +#endif /* NOTMUCH_REGEXP_FIELDS_H */ > diff --git a/test/T630-regexp-query.sh b/test/T630-regexp-query.sh > new file mode 100755 > index 00000000..722af715 > --- /dev/null > +++ b/test/T630-regexp-query.sh > @@ -0,0 +1,82 @@ > +#!/usr/bin/env bash > +test_description=3D'regular expression searches' > +. ./test-lib.sh || exit 1 > + > +add_email_corpus > + > + > +if [ $NOTMUCH_HAVE_XAPIAN_FIELD_PROCESSOR -eq 1 ]; then > + > + notmuch search --output=3Dmessages from:cworth > cworth.msg-ids > + > + test_begin_subtest "regexp from search, case sensitive" > + notmuch search --output=3Dmessages from:/carl/ > OUTPUT > + test_expect_equal_file /dev/null OUTPUT > + > + test_begin_subtest "empty regexp or query" > + notmuch search --output=3Dmessages from:/carl/ or from:/cworth/ > OU= TPUT > + test_expect_equal_file cworth.msg-ids OUTPUT > + > + test_begin_subtest "non-empty regexp and query" > + notmuch search from:/cworth@cworth.org/ and subject:patch > OUTPUT > + cat < EXPECTED > +thread:0000000000000008 2009-11-18 [1/2] Carl Worth| Alex Botero-Lowry= ; [notmuch] [PATCH] Error out if no query is supplied to search instead of = going into an infinite loop (attachment inbox unread) > +thread:0000000000000007 2009-11-18 [1/2] Carl Worth| Ingmar Vanhassel;= [notmuch] [PATCH] Typsos (inbox unread) > +thread:0000000000000018 2009-11-18 [1/2] Carl Worth| Jan Janak; [notmu= ch] [PATCH] Older versions of install do not support -C. (inbox unread) > +thread:0000000000000017 2009-11-18 [1/2] Carl Worth| Keith Packard; [n= otmuch] [PATCH] Make notmuch-show 'X' (and 'x') commands remove inbox (and = unread) tags (inbox unread) > +thread:0000000000000014 2009-11-18 [2/5] Carl Worth| Mikhail Gusarov, = Keith Packard; [notmuch] [PATCH 1/2] Close message file after parsing messa= ge headers (inbox unread) > +thread:0000000000000001 2009-11-18 [1/1] Stewart Smith; [notmuch] [PAT= CH] Fix linking with gcc to use g++ to link in C++ libs. (inbox unread) > +EOF > + test_expect_equal_file EXPECTED OUTPUT > + > + test_begin_subtest "regexp from search, duplicate term search" > + notmuch search --output=3Dmessages from:/cworth/ > OUTPUT > + test_expect_equal_file cworth.msg-ids OUTPUT > + > + test_begin_subtest "long enough regexp matches only desired senders" > + notmuch search --output=3Dmessages 'from:"/C.* Wo/"' > OUTPUT > + test_expect_equal_file cworth.msg-ids OUTPUT > + > + test_begin_subtest "shorter regexp matches one more sender" > + notmuch search --output=3Dmessages 'from:"/C.* W/"' > OUTPUT > + (echo id:1258544095-16616-1-git-send-email-chris@chris-wilson.co.uk = ; cat cworth.msg-ids) > EXPECTED The above doesn't need to be executed in subshell:=20 { echo id:1258544095-16616-1-git-send-email-chris@chris-wilson.co.uk; cat= cworth.msg-ids; } > EXPECTED does it in the same shell > + test_expect_equal_file EXPECTED OUTPUT > + > + test_begin_subtest "regexp subject search, non-ASCII" > + notmuch search --output=3Dmessages subject:/accentu=C3=A9/ > OUTPUT > + echo id:877h1wv7mg.fsf@inf-8657.int-evry.fr > EXPECTED > + test_expect_equal_file EXPECTED OUTPUT > + > + test_begin_subtest "regexp subject search, punctuation" > + notmuch search subject:/\'X\'/ > OUTPUT > + cat < EXPECTED > +thread:0000000000000017 2009-11-18 [2/2] Keith Packard, Carl Worth; [n= otmuch] [PATCH] Make notmuch-show 'X' (and 'x') commands remove inbox (and = unread) tags (inbox unread) > +EOF > + test_expect_equal_file EXPECTED OUTPUT > + > + test_begin_subtest "regexp subject search, no punctuation" > + notmuch search subject:/X/ > OUTPUT > + cat < EXPECTED > +thread:0000000000000017 2009-11-18 [2/2] Keith Packard, Carl Worth; [n= otmuch] [PATCH] Make notmuch-show 'X' (and 'x') commands remove inbox (and = unread) tags (inbox unread) > +thread:000000000000000f 2009-11-18 [4/4] Jjgod Jiang, Alexander Botero= -Lowry; [notmuch] Mac OS X/Darwin compatibility issues (inbox unread) > +EOF > + test_expect_equal_file EXPECTED OUTPUT > + > + test_begin_subtest "combine regexp from and subject" > + notmuch search subject:/-C/ and from:/.an.k/ > OUTPUT > + cat < EXPECTED > +thread:0000000000000018 2009-11-17 [1/2] Jan Janak| Carl Worth; [notmu= ch] [PATCH] Older versions of install do not support -C. (inbox unread) > +EOF > + test_expect_equal_file EXPECTED OUTPUT > + > + test_begin_subtest "regexp error reporting" > + notmuch search 'from:/unbalanced[/' 1>OUTPUT 2>&1 > + cat < EXPECTED > +notmuch search: A Xapian exception occurred > +A Xapian exception occurred performing query: Invalid regular expression > +Query string was: from:/unbalanced[/ > +EOF > + test_expect_equal_file EXPECTED OUTPUT > +fi > + > +test_done > --=20 > 2.11.0