• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

GothenburgBitFactory / taskwarrior / 20661798515

02 Jan 2026 04:15PM UTC coverage: 85.162% (+0.02%) from 85.145%
20661798515

push

github

djmitche
Lexer: isTag: allow whole tag (except first char) to be any non-space (#3957)

Instead of walking the string and stopping at the first
non-isIdentifier() character (see Lexer::isIdentifierStart(),
isIdentifierNext() and isSingleCharOperator()), walk all the way to the
end of the word.  This allows even punctuation and other characters to
be used in tags.

We still need to use isIdentifierStart() for the first character, to
disambiguate it from a negative number or subtraction.  Apparently there
is no command context available, so the parser cannot "know" whether
it's doing a "task calc" and have different parse rules.  The lexing
seems to happen before breaking the arguments down into commands for
dispatch.

5 of 5 new or added lines in 1 file covered. (100.0%)

17 existing lines in 3 files now uncovered.

19588 of 23001 relevant lines covered (85.16%)

23466.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.27
/src/recur.cpp
1
////////////////////////////////////////////////////////////////////////////////
2
//
3
// Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez.
4
//
5
// Permission is hereby granted, free of charge, to any person obtaining a copy
6
// of this software and associated documentation files (the "Software"), to deal
7
// in the Software without restriction, including without limitation the rights
8
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
// copies of the Software, and to permit persons to whom the Software is
10
// furnished to do so, subject to the following conditions:
11
//
12
// The above copyright notice and this permission notice shall be included
13
// in all copies or substantial portions of the Software.
14
//
15
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
// SOFTWARE.
22
//
23
// https://www.opensource.org/licenses/mit-license.php
24
//
25
////////////////////////////////////////////////////////////////////////////////
26

27
#include <cmake.h>
28
// cmake.h include header must come first
29

30
#include <Context.h>
31
#include <Datetime.h>
32
#include <Duration.h>
33
#include <Lexer.h>
34
#include <feedback.h>
35
#include <format.h>
36
#include <pwd.h>
37
#include <recur.h>
38
#include <stdlib.h>
39
#include <sys/types.h>
40
#include <time.h>
41
#include <unicode.h>
42
#include <unistd.h>
43
#include <util.h>
44

45
#include <limits>
46
#include <optional>
47

48
// Add a `time_t` delta to a Datetime, checking for and returning nullopt on integer overflow.
49
std::optional<Datetime> checked_add_datetime(Datetime& base, time_t delta) {
50✔
50
  // Datetime::operator+ takes an integer delta, so check that range
51
  if (static_cast<time_t>(std::numeric_limits<int>::max()) < delta) {
50✔
52
    return std::nullopt;
1✔
53
  }
54

55
  // Check for time_t overflow in the Datetime.
56
  if (std::numeric_limits<time_t>::max() - base.toEpoch() < delta) {
49✔
57
    return std::nullopt;
1✔
58
  }
59
  return base + delta;
48✔
60
}
61

62
////////////////////////////////////////////////////////////////////////////////
63
// Scans all tasks, and for any recurring tasks, determines whether any new
64
// child tasks need to be generated to fill gaps.
65
void handleRecurrence() {
882✔
66
  // Recurrence can be disabled.
67
  // Note: This is currently a workaround for TD-44, TW-1520.
68
  if (!Context::getContext().config.getBoolean("recurrence")) return;
2,646✔
69

70
  auto tasks = Context::getContext().tdb2.pending_tasks();
880✔
71
  Datetime now;
880✔
72

73
  // Look at all tasks and find any recurring ones.
74
  for (auto& t : tasks) {
7,337✔
75
    if (t.getStatus() == Task::recurring) {
6,457✔
76
      // Generate a list of due dates for this recurring task, regardless of
77
      // the mask.
78
      std::vector<Datetime> due;
151✔
79
      if (!generateDueDates(t, due)) {
151✔
80
        // Determine the end date.
81
        t.setStatus(Task::deleted);
1✔
82
        Context::getContext().tdb2.modify(t);
1✔
83
        Context::getContext().footnote(onExpiration(t));
1✔
84
        continue;
1✔
85
      }
86

87
      // Get the mask from the parent task.
88
      auto mask = t.get("mask");
150✔
89

90
      // Iterate over the due dates, and check each against the mask.
91
      auto changed = false;
150✔
92
      unsigned int i = 0;
150✔
93
      for (auto& d : due) {
364✔
94
        if (mask.length() <= i) {
214✔
95
          changed = true;
118✔
96

97
          Task rec(t);                       // Clone the parent.
118✔
98
          rec.setStatus(Task::pending);      // Change the status.
118✔
99
          rec.set("uuid", uuid());           // New UUID.
354✔
100
          rec.set("parent", t.get("uuid"));  // Remember mom.
472✔
101
          rec.setAsNow("entry");             // New entry date.
118✔
102
          rec.set("due", format(d.toEpoch()));
354✔
103

104
          if (t.has("wait")) {
236✔
105
            Datetime old_wait(t.get_date("wait"));
2✔
106
            Datetime old_due(t.get_date("due"));
1✔
107
            Datetime due(d);
1✔
108
            auto wait = checked_add_datetime(due, old_wait - old_due);
1✔
109
            if (wait) {
1✔
110
              rec.set("wait", format(wait->toEpoch()));
3✔
111
            } else {
112
              rec.remove("wait");
×
113
            }
114
            rec.setStatus(Task::waiting);
1✔
115
            mask += 'W';
1✔
116
          } else {
117
            mask += '-';
117✔
118
            rec.setStatus(Task::pending);
117✔
119
          }
120

121
          rec.set("imask", i);
354✔
122
          rec.remove("mask");  // Remove the mask of the parent.
118✔
123

124
          // Add the new task to the DB.
125
          Context::getContext().tdb2.add(rec);
118✔
126
        }
118✔
127

128
        ++i;
214✔
129
      }
130

131
      // Only modify the parent if necessary.
132
      if (changed) {
150✔
133
        t.set("mask", mask);
75✔
134
        Context::getContext().tdb2.modify(t);
75✔
135

136
        if (Context::getContext().verbose("recur"))
225✔
137
          Context::getContext().footnote(
116✔
138
              format("Creating recurring task instance '{1}'", t.get("description")));
290✔
139
      }
140
    }
151✔
141
  }
142
}
880✔
143

144
////////////////////////////////////////////////////////////////////////////////
145
// Determine a start date (due), an optional end date (until), and an increment
146
// period (recur).  Then generate a set of corresponding dates.
147
//
148
// Returns false if the parent recurring task is depleted.
149
bool generateDueDates(Task& parent, std::vector<Datetime>& allDue) {
151✔
150
  // Determine due date, recur period and until date.
151
  Datetime due(parent.get_date("due"));
151✔
152
  if (due._date == 0) return false;
151✔
153

154
  std::string recur = parent.get("recur");
150✔
155

156
  bool specificEnd = false;
150✔
157
  Datetime until;
150✔
158
  if (parent.get("until") != "") {
300✔
159
    until = Datetime(parent.get("until"));
15✔
160
    specificEnd = true;
5✔
161
  }
162

163
  auto recurrence_limit = Context::getContext().config.getInteger("recurrence.limit");
300✔
164
  int recurrence_counter = 0;
150✔
165
  Datetime now;
150✔
166
  Datetime i = due;
150✔
167
  while (1) {
168
    allDue.push_back(i);
214✔
169

170
    if (specificEnd && i > until) {
214✔
171
      // If i > until, it means there are no more tasks to generate, and if the
172
      // parent mask contains all + or X, then there never will be another task
173
      // to generate, and this parent task may be safely reaped.
174
      auto mask = parent.get("mask");
3✔
175
      if (mask.length() == allDue.size() && mask.find('-') == std::string::npos) return false;
3✔
176

177
      return true;
3✔
178
    }
3✔
179

180
    if (i > now) ++recurrence_counter;
211✔
181

182
    if (recurrence_counter >= recurrence_limit) return true;
211✔
183
    auto next = getNextRecurrence(i, recur);
66✔
184
    if (next) {
66✔
185
      i = *next;
64✔
186
    } else {
187
      return true;
2✔
188
    }
189
  }
64✔
190

191
  return true;
192
}
150✔
193

194
////////////////////////////////////////////////////////////////////////////////
195
/// Determine the next recurrence of the given period.
196
///
197
/// If no such date can be calculated, such as with a very large period, returns
198
/// nullopt.
199
std::optional<Datetime> getNextRecurrence(Datetime& current, std::string& period) {
66✔
200
  auto m = current.month();
66✔
201
  auto d = current.day();
66✔
202
  auto y = current.year();
66✔
203
  auto ho = current.hour();
66✔
204
  auto mi = current.minute();
66✔
205
  auto se = current.second();
66✔
206

207
  // Some periods are difficult, because they can be vague.
208
  if (period == "monthly" || period == "P1M") {
66✔
209
    if (++m > 12) {
×
210
      m -= 12;
×
211
      ++y;
×
212
    }
213

214
    while (!Datetime::valid(y, m, d)) --d;
×
215

216
    return Datetime(y, m, d, ho, mi, se);
×
217
  }
218

219
  else if (period == "weekdays") {
66✔
220
    auto dow = current.dayOfWeek();
1✔
221
    int days;
222

223
    if (dow == 5)
1✔
224
      days = 3;
1✔
225
    else if (dow == 6)
×
226
      days = 2;
×
227
    else
228
      days = 1;
×
229

230
    return checked_add_datetime(current, days * 86400);
1✔
231
  }
232

233
  else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'm') {
65✔
234
    int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
1✔
235

236
    if (increment <= 0)
1✔
237
      throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
238
                   increment);
×
239

240
    m += increment;
1✔
241
    while (m > 12) {
1✔
UNCOV
242
      m -= 12;
×
UNCOV
243
      ++y;
×
244
    }
245

246
    while (!Datetime::valid(y, m, d)) --d;
1✔
247

248
    return Datetime(y, m, d, ho, mi, se);
1✔
249
  }
250

251
  else if (period[0] == 'P' && Lexer::isAllDigits(period.substr(1, period.length() - 2)) &&
64✔
252
           period[period.length() - 1] == 'M') {
×
253
    int increment = strtol(period.substr(1, period.length() - 2).c_str(), nullptr, 10);
×
254

255
    if (increment <= 0)
×
256
      throw format("Recurrence period '{1}' is equivalent to {2} and hence invalid.", period,
257
                   increment);
×
258

259
    m += increment;
×
260
    while (m > 12) {
×
261
      m -= 12;
×
262
      ++y;
×
263
    }
264

265
    while (!Datetime::valid(y, m, d)) --d;
×
266

267
    return Datetime(y, m, d);
×
268
  }
269

270
  else if (period == "quarterly" || period == "P3M") {
64✔
271
    m += 3;
×
272
    if (m > 12) {
×
273
      m -= 12;
×
274
      ++y;
×
275
    }
276

277
    while (!Datetime::valid(y, m, d)) --d;
×
278

279
    return Datetime(y, m, d, ho, mi, se);
×
280
  }
281

282
  else if (unicodeLatinDigit(period[0]) && period[period.length() - 1] == 'q') {
64✔
283
    int increment = strtol(period.substr(0, period.length() - 1).c_str(), nullptr, 10);
1✔
284

285
    if (increment <= 0) {
1✔
286
      Context::getContext().footnote(format(
×
287
          "Recurrence period '{1}' is equivalent to {2} and hence invalid.", period, increment));
288
      return std::nullopt;
×
289
    }
290

291
    m += 3 * increment;
1✔
292
    while (m > 12) {
3✔
293
      m -= 12;
2✔
294
      ++y;
2✔
295
    }
296

297
    while (!Datetime::valid(y, m, d)) --d;
1✔
298

299
    return Datetime(y, m, d, ho, mi, se);
1✔
300
  }
301

302
  else if (period == "semiannual" || period == "P6M") {
63✔
303
    m += 6;
×
304
    if (m > 12) {
×
305
      m -= 12;
×
306
      ++y;
×
307
    }
308

309
    while (!Datetime::valid(y, m, d)) --d;
×
310

311
    return Datetime(y, m, d, ho, mi, se);
×
312
  }
313

314
  else if (period == "bimonthly" || period == "P2M") {
63✔
315
    m += 2;
×
316
    if (m > 12) {
×
317
      m -= 12;
×
318
      ++y;
×
319
    }
320

321
    while (!Datetime::valid(y, m, d)) --d;
×
322

323
    return Datetime(y, m, d, ho, mi, se);
×
324
  }
325

326
  else if (period == "biannual" || period == "biyearly" || period == "P2Y") {
63✔
327
    y += 2;
×
328

329
    return Datetime(y, m, d, ho, mi, se);
×
330
  }
331

332
  else if (period == "annual" || period == "yearly" || period == "P1Y") {
63✔
333
    y += 1;
16✔
334

335
    // If the due data just happens to be 2/29 in a leap year, then simply
336
    // incrementing y is going to create an invalid date.
337
    if (m == 2 && d == 29) d = 28;
16✔
338

339
    return Datetime(y, m, d, ho, mi, se);
16✔
340
  }
341

342
  // Add the period to current, and we're done.
343
  std::string::size_type idx = 0;
47✔
344
  Duration p;
47✔
345
  if (!p.parse(period, idx)) {
47✔
346
    Context::getContext().footnote(
4✔
347
        format("Warning: The recurrence value '{1}' is not valid.", period));
8✔
348
    return std::nullopt;
2✔
349
  }
350

351
  return checked_add_datetime(current, p.toTime_t());
45✔
352
}
353

354
////////////////////////////////////////////////////////////////////////////////
355
// When the status of a recurring child task changes, the parent task must
356
// update it's mask.
357
void updateRecurrenceMask(Task& task) {
368✔
358
  auto uuid = task.get("parent");
368✔
359
  Task parent;
368✔
360

361
  if (uuid != "" && Context::getContext().tdb2.get(uuid, parent)) {
368✔
362
    unsigned int index = strtol(task.get("imask").c_str(), nullptr, 10);
58✔
363
    auto mask = parent.get("mask");
29✔
364
    if (mask.length() > index) {
29✔
365
      mask[index] = (task.getStatus() == Task::pending)     ? '-'
47✔
366
                    : (task.getStatus() == Task::completed) ? '+'
36✔
367
                    : (task.getStatus() == Task::deleted)   ? 'X'
18✔
368
                    : (task.getStatus() == Task::waiting)   ? 'W'
×
369
                                                            : '?';
370
    } else {
371
      std::string mask;
×
372
      for (unsigned int i = 0; i < index; ++i) mask += "?";
×
373

374
      mask += (task.getStatus() == Task::pending)     ? '-'
×
375
              : (task.getStatus() == Task::completed) ? '+'
×
376
              : (task.getStatus() == Task::deleted)   ? 'X'
×
377
              : (task.getStatus() == Task::waiting)   ? 'W'
×
378
                                                      : '?';
×
379
    }
×
380

381
    parent.set("mask", mask);
29✔
382
    Context::getContext().tdb2.modify(parent);
29✔
383
  }
29✔
384
}
368✔
385

386
////////////////////////////////////////////////////////////////////////////////
387
// Delete expired tasks.
388
void handleUntil() {
882✔
389
  Datetime now;
882✔
390
  auto tasks = Context::getContext().tdb2.pending_tasks();
882✔
391
  for (auto& t : tasks) {
7,341✔
392
    // TODO What about expiring template tasks?
393
    if (t.getStatus() == Task::pending && t.has("until")) {
19,029✔
394
      auto until = Datetime(t.get_date("until"));
14✔
395
      if (until < now) {
14✔
396
        Context::getContext().debug(format("handleUntil: recurrence expired until {1} < now {2}",
8✔
397
                                           until.toISOLocalExtended(), now.toISOLocalExtended()));
8✔
398
        t.setStatus(Task::deleted);
4✔
399
        Context::getContext().tdb2.modify(t);
4✔
400
        Context::getContext().footnote(onExpiration(t));
4✔
401
      }
402
    }
403
  }
404
}
882✔
405

406
////////////////////////////////////////////////////////////////////////////////
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc