timewarrior/src/CLI.cpp
Thomas Lauf 63f230013a Detect whether a date is meant as range
- Add DatetimeParser::parse_range: If a date contains no time, it is assumed to be a fixed range, else an open range starting at given datetime
- Add tests for summary with named dates 'yesterday' and 'today'
- Remove closing of filter in CmdSummary.cpp
- Closes #333

Signed-off-by: Thomas Lauf <thomas.lauf@tngtech.com>
2020-06-28 20:52:43 +02:00

835 lines
22 KiB
C++

////////////////////////////////////////////////////////////////////////////////
//
// Copyright 2016 - 2020, Thomas Lauf, Paul Beckingham, Federico Hernandez.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// https://www.opensource.org/licenses/mit-license.php
//
////////////////////////////////////////////////////////////////////////////////
#include <cmake.h>
#include <CLI.h>
#include <Color.h>
#include <Pig.h>
#include <shared.h>
#include <format.h>
#include <utf8.h>
#include <sstream>
#include <algorithm>
#include <set>
#include <Duration.h>
#include <timew.h>
#include "DatetimeParser.h"
////////////////////////////////////////////////////////////////////////////////
A2::A2 (const std::string& raw, Lexer::Type lextype)
{
_lextype = lextype;
attribute ("raw", raw);
}
////////////////////////////////////////////////////////////////////////////////
bool A2::hasTag (const std::string& tag) const
{
return std::find (_tags.begin (), _tags.end (), tag) != _tags.end ();
}
////////////////////////////////////////////////////////////////////////////////
void A2::tag (const std::string& tag)
{
if (! hasTag (tag))
_tags.push_back (tag);
}
////////////////////////////////////////////////////////////////////////////////
void A2::unTag (const std::string& tag)
{
for (auto i = _tags.begin (); i != _tags.end (); ++i)
{
if (*i == tag)
{
_tags.erase (i);
break;
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Accessor for attributes.
void A2::attribute (const std::string& name, const std::string& value)
{
_attributes[name] = value;
}
////////////////////////////////////////////////////////////////////////////////
// Accessor for attributes.
void A2::attribute (const std::string& name, int value)
{
_attributes[name] = format ("{1}", value);
}
////////////////////////////////////////////////////////////////////////////////
// Accessor for attributes.
std::string A2::attribute (const std::string& name) const
{
// Prevent autovivification.
auto i = _attributes.find (name);
if (i != _attributes.end ())
return i->second;
return "";
}
////////////////////////////////////////////////////////////////////////////////
std::string A2::getToken () const
{
auto i = _attributes.find ("canonical");
if (i == _attributes.end ())
i = _attributes.find ("raw");
return i->second;
}
////////////////////////////////////////////////////////////////////////////////
std::string A2::dump () const
{
auto output = Lexer::typeToString (_lextype);
// Dump attributes.
std::string atts;
for (auto& a : _attributes)
atts += a.first + "='\033[33m" + a.second + "\033[0m' ";
// Dump tags.
std::string tags;
for (const auto& tag : _tags)
{
if (tag == "BINARY") tags += "\033[1;37;44m" + tag + "\033[0m ";
else if (tag == "CMD") tags += "\033[1;37;46m" + tag + "\033[0m ";
else if (tag == "EXT") tags += "\033[1;37;42m" + tag + "\033[0m ";
else if (tag == "HINT") tags += "\033[1;37;43m" + tag + "\033[0m ";
else if (tag == "FILTER") tags += "\033[1;37;45m" + tag + "\033[0m ";
else if (tag == "CONFIG") tags += "\033[1;37;101m" + tag + "\033[0m ";
else if (tag == "ID") tags += "\033[38;5;7m\033[48;5;34m" + tag + "\033[0m ";
else tags += "\033[32m" + tag + "\033[0m ";
}
return output + " " + atts + tags;
}
////////////////////////////////////////////////////////////////////////////////
void CLI::entity (const std::string& category, const std::string& name)
{
// Walk the list of entities for category.
auto c = _entities.equal_range (category);
for (auto e = c.first; e != c.second; ++e)
if (e->second == name)
return;
// The category/name pair was not found, therefore add it.
_entities.insert (std::pair <std::string, std::string> (category, name));
}
////////////////////////////////////////////////////////////////////////////////
// Capture a single argument.
void CLI::add (const std::string& argument)
{
// Sanitize the input: Convert control charts to spaces. Then trim.
std::string clean;
std::string::size_type i = 0;
int character;
while ((character = utf8_next_char (argument.c_str (), i)))
{
if (character <= 32)
clean += ' ';
else
clean += utf8_character (character);
}
A2 arg (Lexer::trim (clean), Lexer::Type::word);
arg.tag ("ORIGINAL");
_original_args.push_back (arg);
// Adding a new argument invalidates prior analysis.
_args.clear ();
}
////////////////////////////////////////////////////////////////////////////////
// Arg0 is the first argument, which is the name and potentially a relative or
// absolute path to the invoked binary.
//
// The binary name is 'timew', but if the binary is reported as 'foo' then it
// was invoked via symbolic link, in which case capture the first argument as
// 'foo'. This should allow any command/extension to do this.
//
void CLI::handleArg0 ()
{
// Capture arg0 separately, because it is the command that was run, and could
// need special handling.
auto raw = _original_args[0].attribute ("raw");
A2 a (raw, Lexer::Type::word);
a.tag ("BINARY");
std::string basename = "timew";
auto slash = raw.rfind ('/');
if (slash != std::string::npos)
basename = raw.substr (slash + 1);
a.attribute ("basename", basename);
if (basename != "timew")
{
A2 cal (basename, Lexer::Type::word);
_args.push_back (cal);
}
_args.push_back (a);
}
////////////////////////////////////////////////////////////////////////////////
// All arguments must be individually and wholly recognized by the Lexer. Any
// argument not recognized is considered a Lexer::Type::word.
void CLI::lexArguments ()
{
// Note: Starts iterating at index 1, because ::handleArg0 has already
// processed 0.
for (unsigned int i = 1; i < _original_args.size (); ++i)
{
bool quoted = Lexer::wasQuoted (_original_args[i].attribute ("raw"));
std::string lexeme;
Lexer::Type type;
Lexer lex (_original_args[i].attribute ("raw"));
if (lex.token (lexeme, type) &&
lex.isEOS ())
{
A2 a (Lexer::dequote (_original_args[i].attribute ("raw")), type);
if (quoted)
a.tag ("QUOTED");
if (_original_args[i].hasTag ("ORIGINAL"))
a.tag ("ORIGINAL");
_args.push_back (a);
}
else
{
std::string quote = "'";
auto escaped = str_replace (_original_args[i].attribute ("raw"), quote, "\\'");
std::string::size_type cursor = 0;
std::string word;
if (Lexer::readWord (quote + escaped + quote, quote, cursor, word))
{
A2 unknown (Lexer::dequote (word), Lexer::Type::word);
if (Lexer::wasQuoted (_original_args[i].attribute ("raw")))
unknown.tag ("QUOTED");
if (_original_args[i].hasTag ("ORIGINAL"))
unknown.tag ("ORIGINAL");
_args.push_back (unknown);
}
// This branch may have no use-case.
else
{
A2 unknown (Lexer::dequote (_original_args[i].attribute ("raw")), Lexer::Type::word);
unknown.tag ("UNKNOWN");
if (Lexer::wasQuoted (_original_args[i].attribute ("raw")))
unknown.tag ("QUOTED");
if (_original_args[i].hasTag ("ORIGINAL"))
unknown.tag ("ORIGINAL");
_args.push_back (unknown);
}
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Intended to be called after ::add() to perform the final analysis.
void CLI::analyze ()
{
// Process _original_args.
_args.clear ();
handleArg0 ();
lexArguments ();
identifyOverrides ();
identifyIds ();
canonicalizeNames ();
identifyFilter ();
}
////////////////////////////////////////////////////////////////////////////////
// Return all the unknown args.
std::vector <std::string> CLI::getWords () const
{
std::vector <std::string> words;
for (const auto& a : _args)
if (! a.hasTag ("BINARY") &&
! a.hasTag ("CMD") &&
! a.hasTag ("CONFIG") &&
! a.hasTag ("HINT"))
words.push_back (a.attribute ("raw"));
return words;
}
////////////////////////////////////////////////////////////////////////////////
// Search for 'value' in _entities category, return canonicalized value.
bool CLI::canonicalize (
std::string& canonicalized,
const std::string& category,
const std::string& value) const
{
// Extract a list of entities for category.
std::vector <std::string> options;
auto c = _entities.equal_range (category);
for (auto e = c.first; e != c.second; ++e)
{
// Shortcut: if an exact match is found, success.
if (value == e->second)
{
canonicalized = value;
return true;
}
options.push_back (e->second);
}
// Match against the options, throw away results.
std::vector <std::string> matches;
if (autoComplete (value, options, matches) == 1)
{
canonicalized = matches[0];
return true;
}
return false;
}
////////////////////////////////////////////////////////////////////////////////
std::string CLI::getBinary () const
{
if (! _args.empty ())
return _args[0].attribute ("raw");
return "";
}
////////////////////////////////////////////////////////////////////////////////
std::string CLI::getCommand () const
{
for (const auto& a : _args)
if (a.hasTag ("CMD"))
return a.attribute ("canonical");
for (const auto& a : _args)
if (a.hasTag ("EXT"))
return a.attribute ("canonical");
return "";
}
////////////////////////////////////////////////////////////////////////////////
std::string CLI::dump (const std::string& title) const
{
std::stringstream out;
out << "\033[1m" << title << "\033[0m\n"
<< " _original_args\n ";
Color colorArgs ("gray10 on gray4");
for (auto i = _original_args.begin (); i != _original_args.end (); ++i)
{
if (i != _original_args.begin ())
out << ' ';
out << colorArgs.colorize (i->attribute ("raw"));
}
out << '\n';
if (! _args.empty ())
{
out << " _args\n";
for (auto& a : _args)
out << " " << a.dump () << '\n';
}
return out.str ();
}
////////////////////////////////////////////////////////////////////////////////
// Scan all arguments and identify instances of 'rc.<name>[:=]<value>'.
void CLI::identifyOverrides ()
{
for (auto& a : _args)
{
auto raw = a.attribute ("raw");
if (raw.length () > 3 &&
raw.substr (0, 3) == "rc.")
{
auto sep = raw.find ('=', 3);
if (sep == std::string::npos)
sep = raw.find (':', 3);
if (sep != std::string::npos)
{
a.tag ("CONFIG");
a.attribute ("name", raw.substr (3, sep - 3));
a.attribute ("value", raw.substr (sep + 1));
}
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Scan all arguments and identify instances of '@<integer>'.
void CLI::identifyIds ()
{
for (auto& a : _args)
{
if (a._lextype == Lexer::Type::word)
{
Pig pig (a.attribute ("raw"));
int digits;
if (pig.skipLiteral ("@") &&
pig.getDigits (digits) &&
pig.eos ())
{
if (digits <= 0)
throw format ("'@{1}' is not a valid ID.", digits);
a.tag ("ID");
a.attribute ("value", digits);
}
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Scan all arguments and canonicalize names that need it.
void CLI::canonicalizeNames ()
{
bool alreadyFoundCmd = false;
for (auto& a : _args)
{
auto raw = a.attribute ("raw");
std::string canonical = raw;
// Commands.
if (! alreadyFoundCmd &&
(exactMatch ("command", raw) ||
canonicalize (canonical, "command", raw)))
{
a.attribute ("canonical", canonical);
a.tag ("CMD");
alreadyFoundCmd = true;
}
// 'timew <command> --help|-h' should be treated the same as 'timew help <command>'.
// Therefore, '--help|-h' on the command line should always become the command.
else if (alreadyFoundCmd && (raw == "--help" || raw == "-h"))
{
for (auto& b : _args) {
if (b.hasTag("CMD"))
{
b.unTag("CMD");
break;
}
}
a.tag ("CMD");
a.attribute("canonical", canonical);
}
// Hints.
else if (exactMatch ("hint", raw) ||
canonicalize (canonical, "hint", raw))
{
a.attribute ("canonical", canonical);
a.tag ("HINT");
}
// Extensions.
else if (exactMatch ("extension", raw) ||
canonicalize (canonical, "extension", raw))
{
a.attribute ("canonical", canonical);
a.tag ("EXT");
alreadyFoundCmd = true;
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Locate arguments that are part of a filter.
void CLI::identifyFilter ()
{
for (auto& a : _args)
{
if (a.hasTag ("CMD") ||
a.hasTag ("EXT") ||
a.hasTag ("CONFIG") ||
a.hasTag ("BINARY"))
continue;
auto raw = a.attribute ("raw");
if (a.hasTag ("HINT"))
a.tag ("FILTER");
else if (a.hasTag ("ID"))
a.tag ("FILTER");
else if (a._lextype == Lexer::Type::date ||
a._lextype == Lexer::Type::duration)
{
a.tag ("FILTER");
}
else if (raw == "from" ||
raw == "since" ||
raw == "to" ||
raw == "for" ||
raw == "until" ||
raw == "-" ||
raw == "before" ||
raw == "after" ||
raw == "ago")
{
a.tag ("FILTER");
a.tag ("KEYWORD");
}
else if (raw.rfind("dom.",0) == 0)
{
a.tag ("DOM");
}
else
{
a.tag ("FILTER");
a.tag ("TAG");
}
}
}
////////////////////////////////////////////////////////////////////////////////
// Search for exact 'value' in _entities category.
bool CLI::exactMatch (
const std::string& category,
const std::string& value) const
{
// Extract a list of entities for category.
auto c = _entities.equal_range (category);
for (auto e = c.first; e != c.second; ++e)
if (value == e->second)
return true;
return false;
}
////////////////////////////////////////////////////////////////////////////////
std::set<int> CLI::getIds() const
{
std::set <int> ids;
for (auto& arg : _args)
{
if (arg.hasTag ("ID"))
ids.insert (strtol (arg.attribute ("value").c_str (), NULL, 10));
}
return ids;
}
////////////////////////////////////////////////////////////////////////////////
std::vector<std::string> CLI::getTags () const
{
std::vector <std::string> tags;
for (auto& arg : _args)
{
if (arg.hasTag ("TAG"))
tags.push_back (arg.attribute ("raw"));
}
return tags;
}
////////////////////////////////////////////////////////////////////////////////
std::string CLI::getAnnotation () const
{
std::string annotation;
for (auto& arg : _args)
{
if (arg.hasTag ("TAG"))
{
annotation = (arg.attribute ("raw"));
}
}
return annotation;
}
////////////////////////////////////////////////////////////////////////////////
Duration CLI::getDuration () const
{
std::string delta;
for (auto& arg : _args)
{
if (arg.hasTag ("FILTER") &&
arg._lextype == Lexer::Type::duration)
{
delta = arg.attribute ("raw");
}
}
Duration dur (delta);
return dur;
}
////////////////////////////////////////////////////////////////////////////////
std::vector <std::string> CLI::getDomReferences () const
{
std::vector<std::string> references;
for (auto &arg : _args)
{
if (arg.hasTag ("DOM"))
{
references.emplace_back (arg.attribute ("raw"));
}
}
return references;
}
////////////////////////////////////////////////////////////////////////////////
// A filter is just another interval, containing start, end and tags.
//
// Supported interval forms:
// ["from"] <date> ["to"|"-" <date>]
// ["from"] <date> "for" <duration>
// <duration> ["before"|"after" <date>]
// <duration> "ago"
//
Interval CLI::getFilter (const Range& default_range) const
{
// One instance, so we can directly compare.
Datetime now;
Interval filter;
std::string start;
std::string end;
std::string duration;
std::vector <std::string> args;
for (auto& arg : _args)
{
if (arg.hasTag ("BINARY") ||
arg.hasTag ("CMD") ||
arg.hasTag ("EXT"))
continue;
if (arg.hasTag ("FILTER"))
{
auto canonical = arg.attribute ("canonical");
auto raw = arg.attribute ("raw");
if (arg.hasTag ("HINT"))
{
Range range;
if (expandIntervalHint (canonical, range))
{
if (range.is_empty ())
{
args.push_back ("<all>");
}
else
{
start = range.start.toISO ();
end = range.end.toISO ();
args.push_back ("<date>");
args.push_back ("-");
args.push_back ("<date>");
}
}
// Hints that are not expandable to a date range are ignored.
}
else if (arg._lextype == Lexer::Type::date)
{
if (start.empty ())
start = raw;
else if (end.empty ())
end = raw;
args.push_back ("<date>");
}
else if (arg._lextype == Lexer::Type::duration)
{
if (duration.empty ())
duration = raw;
args.push_back ("<duration>");
}
else if (arg.hasTag ("KEYWORD"))
{
// Note: that KEYWORDS are not entities (why not?) and there is a list
// in CLI.cpp of them that must be maintained and synced with this
// function.
args.push_back (raw);
}
else if (arg.hasTag ("ID"))
{
// Not part of a filter.
}
else
{
filter.tag (raw);
}
}
}
if (args.empty ())
{
filter.setRange(default_range);
}
// <date>
else if (args.size () == 1 &&
args[0] == "<date>")
{
DatetimeParser dtp;
Range range = dtp.parse_range(start);
filter.setRange (range);
}
// from <date>
else if (args.size () == 2 &&
args[0] == "from" &&
args[1] == "<date>")
{
filter.setRange ({Datetime (start), 0});
}
// <date> to/- <date>
else if (args.size () == 3 &&
args[0] == "<date>" &&
(args[1] == "to" || args[1] == "-") &&
args[2] == "<date>")
{
filter.setRange ({Datetime (start), Datetime (end)});
}
// from <date> to/- <date>
else if (args.size () == 4 &&
args[0] == "from" &&
args[1] == "<date>" &&
(args[2] == "to" || args[2] == "-") &&
args[3] == "<date>")
{
filter.setRange ({Datetime (start), Datetime (end)});
}
// <date> for <duration>
else if (args.size () == 3 &&
args[0] == "<date>" &&
args[1] == "for" &&
args[2] == "<duration>")
{
filter.setRange ({Datetime (start), Datetime (start) + Duration (duration).toTime_t ()});
}
// from <date> for <duration>
else if (args.size () == 4 &&
args[0] == "from" &&
args[1] == "<date>" &&
args[2] == "for" &&
args[3] == "<duration>")
{
filter.setRange ({Datetime (start), Datetime (start) + Duration (duration).toTime_t ()});
}
// <duration> before <date>
else if (args.size () == 3 &&
args[0] == "<duration>" &&
args[1] == "before" &&
args[2] == "<date>")
{
filter.setRange ({Datetime (start) - Duration (duration).toTime_t (), Datetime (start)});
}
// <duration> after <date>
else if (args.size () == 3 &&
args[0] == "<duration>" &&
args[1] == "after" &&
args[2] == "<date>")
{
filter.setRange ({Datetime (start), Datetime (start) + Duration (duration).toTime_t ()});
}
// <duration> ago
else if (args.size () == 2 &&
args[0] == "<duration>" &&
args[1] == "ago")
{
filter.setRange ({now - Duration (duration).toTime_t (), 0});
}
// for <duration>
else if (args.size () == 2 &&
args[0] == "for" &&
args[1] == "<duration>")
{
filter.setRange ({now - Duration (duration).toTime_t (), now});
}
// <duration>
else if (args.size () == 1 &&
args[0] == "<duration>")
{
filter.setRange ({now - Duration (duration).toTime_t (), now});
}
// :all
else if (args.size () == 1 && args[0] == "<all>")
{
filter.setRange (0, 0);
}
// Unrecognized date range construct.
else if (! args.empty ())
{
throw std::string ("Unrecognized date range: '") + join (" ", args) + "'.";
}
if (filter.end != 0 && filter.start > filter.end)
throw std::string ("The end of a date range must be after the start.");
return filter;
}