Skip to content

Commit

Permalink
refactor: Make TagsField a special MultiTextField; rename Field.filte…
Browse files Browse the repository at this point in the history
…rRegexp() (#1173)

* Refactor: Make TagsField a special MultiTextField

* Move the MultiTextField class to a separate file

* Add some more JSdoc comments

* Fix wording in JSDoc

* Make variable and method names more specific

* Use older, more compatible import syntax

* Use JavaScript spelling of RegExp everywhere
  • Loading branch information
Cito authored Sep 23, 2022
1 parent 3fbb2d4 commit a5b1cf8
Show file tree
Hide file tree
Showing 13 changed files with 116 additions and 81 deletions.
2 changes: 1 addition & 1 deletion src/Query/Filter/BooleanField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class BooleanField extends Field {
private readonly supportedOperators = ['AND', 'OR', 'XOR', 'NOT'];
private subFields: Record<string, Filter> = {};

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return this.basicBooleanRegexp;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Query/Filter/DateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export abstract class DateField extends Field {
return result;
}

const match = Field.getMatch(this.filterRegexp(), line);
const match = Field.getMatch(this.filterRegExp(), line);
if (match !== null) {
const filterDate = DateParser.parseDate(match[2]);
if (!filterDate.isValid()) {
Expand Down
2 changes: 1 addition & 1 deletion src/Query/Filter/DoneDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DateField } from './DateField';
export class DoneDateField extends DateField {
private static readonly doneRegexp = /^done (before|after|on)? ?(.*)/;

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return DoneDateField.doneRegexp;
}
public fieldName(): string {
Expand Down
2 changes: 1 addition & 1 deletion src/Query/Filter/DueDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DateField } from './DateField';
export class DueDateField extends DateField {
private static readonly dueRegexp = /^due (before|after|on)? ?(.*)/;

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return DueDateField.dueRegexp;
}
public fieldName(): string {
Expand Down
14 changes: 7 additions & 7 deletions src/Query/Filter/Field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ export abstract class Field {
* Returns true if the class can parse the given instruction line.
*
* Current implementation simply checks whether the line matches
* this.filterRegexp().
* this.filterRegExp().
* @param line - A line from a ```tasks``` block.
*/
public canCreateFilterForLine(line: string): boolean {
return Field.lineMatchesFilter(this.filterRegexp(), line);
return Field.lineMatchesFilter(this.filterRegExp(), line);
}

/**
Expand All @@ -49,14 +49,14 @@ export abstract class Field {

/**
* Return the match for the given filter, or null if it does not match
* @param filterRegexp - A RegExp regular expression, that specifies one query instruction.
* @param filterRegExp - A RegExp regular expression, that specifies one query instruction.
* Or null, if the field does not support regexp-based filtering.
* @param line - A line from a tasks code block query.
* @protected
*/
protected static getMatch(filterRegexp: RegExp | null, line: string): RegExpMatchArray | null {
if (filterRegexp) {
return line.match(filterRegexp);
protected static getMatch(filterRegExp: RegExp | null, line: string): RegExpMatchArray | null {
if (filterRegExp) {
return line.match(filterRegExp);
} else {
return null;
}
Expand All @@ -68,7 +68,7 @@ export abstract class Field {
* Or null, if this field does not have a regex-based instruction.
* @protected
*/
protected abstract filterRegexp(): RegExp | null;
protected abstract filterRegExp(): RegExp | null;

/**
* Return the name of this field, to be used in error messages.
Expand Down
2 changes: 1 addition & 1 deletion src/Query/Filter/FilterInstructionsBasedField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export abstract class FilterInstructionsBasedField extends Field {
return this._filters.createFilterOrErrorMessage(line);
}

protected filterRegexp(): RegExp | null {
protected filterRegExp(): RegExp | null {
return null;
}
}
4 changes: 2 additions & 2 deletions src/Query/Filter/HappensDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class HappensDateField extends Field {
return result;
}

const happensMatch = Field.getMatch(this.filterRegexp(), line);
const happensMatch = Field.getMatch(this.filterRegExp(), line);
if (happensMatch !== null) {
const filterDate = DateParser.parseDate(happensMatch[2]);
if (!filterDate.isValid()) {
Expand Down Expand Up @@ -78,7 +78,7 @@ export class HappensDateField extends Field {
return sortedHappensDates[0];
}

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return HappensDateField.happensRegexp;
}

Expand Down
61 changes: 61 additions & 0 deletions src/Query/Filter/MultiTextField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Task } from '../../Task';
import type { IStringMatcher } from '../Matchers/IStringMatcher';
import { TextField } from './TextField';
import type { Filter } from './Filter';

/**
* MultiTextField is an abstract base class to help implement
* all the filter instructions that act on multiple string values
* such as the tags.
*/
export abstract class MultiTextField extends TextField {
/**
* Returns the singular form of the field's name.
*/
public abstract fieldNameSingular(): string;

/**
* Returns the plural form of the field's name.
* If not overridden, returns the singular form appended with an "s".
*/
protected fieldNamePlural(): string {
return this.fieldNameSingular() + 's';
}

public fieldName(): string {
return `${this.fieldNameSingular()}/${this.fieldNamePlural()}`;
}

protected fieldPattern(): string {
return `${this.fieldNameSingular()}|${this.fieldNamePlural()}`;
}

protected filterOperatorPattern(): string {
return `${super.filterOperatorPattern()}|include|do not include`;
}

/**
* If not overridden, returns a comma-separated concatenation of all
* the values of this field or an empty string if there are not values
* @param task
* @public
*/
public value(task: Task): string {
return this.values(task).join(', ');
}

/**
* Returns the array of values of this field, or an empty array
* if the field has no values
* @param task
* @public
*/
public abstract values(task: Task): string[];

protected getFilter(matcher: IStringMatcher, negate: boolean): Filter {
return (task: Task) => {
const match = matcher!.matchesAnyOf(this.values(task));
return negate ? !match : match;
};
}
}
4 changes: 2 additions & 2 deletions src/Query/Filter/PriorityField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class PriorityField extends Field {

createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
const result = new FilterOrErrorMessage();
const priorityMatch = Field.getMatch(this.filterRegexp(), line);
const priorityMatch = Field.getMatch(this.filterRegExp(), line);
if (priorityMatch !== null) {
const filterPriorityString = priorityMatch[3];
let filterPriority: Priority | null = null;
Expand Down Expand Up @@ -52,7 +52,7 @@ export class PriorityField extends Field {
return 'priority';
}

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return PriorityField.priorityRegexp;
}
}
2 changes: 1 addition & 1 deletion src/Query/Filter/ScheduledDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DateField } from './DateField';
export class ScheduledDateField extends DateField {
private static readonly scheduledRegexp = /^scheduled (before|after|on)? ?(.*)/;

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return ScheduledDateField.scheduledRegexp;
}
public fieldName(): string {
Expand Down
2 changes: 1 addition & 1 deletion src/Query/Filter/StartDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DateField } from './DateField';
export class StartDateField extends DateField {
private static readonly startRegexp = /^starts (before|after|on)? ?(.*)/;

protected filterRegexp(): RegExp {
protected filterRegExp(): RegExp {
return StartDateField.startRegexp;
}
public fieldName(): string {
Expand Down
55 changes: 6 additions & 49 deletions src/Query/Filter/TagsField.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,17 @@
import type { Task } from '../../Task';
import { SubstringMatcher } from '../Matchers/SubstringMatcher';
import { RegexMatcher } from '../Matchers/RegexMatcher';
import type { IStringMatcher } from '../Matchers/IStringMatcher';
import { Field } from './Field';
import { FilterOrErrorMessage } from './Filter';
import { TextField } from './TextField';
import { MultiTextField } from './MultiTextField';

/**
* Support the 'tag' and 'tags' search instructions.
*
* Tags can be searched for with and without the hash tag at the start.
*/
export class TagsField extends Field {
// Handles both ways of referencing the tags query.
private static readonly tagRegexp =
/^(tag|tags) (includes|does not include|include|do not include|regex matches|regex does not match) (.*)/;

public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
const match = Field.getMatch(this.filterRegexp(), line);
if (match === null) {
return FilterOrErrorMessage.fromError(`do not understand query filter (${this.fieldName()})`);
}
const filterMethod = match[2];
const search = match[3];
let matcher: IStringMatcher | null = null;
if (filterMethod.includes('include')) {
matcher = new SubstringMatcher(search);
} else if (filterMethod.includes('regex')) {
matcher = RegexMatcher.validateAndConstruct(search);
if (matcher === null) {
return FilterOrErrorMessage.fromError(
`cannot parse regex (${this.fieldName()}); check your leading and trailing slashes for your query`,
);
}
}

if (matcher === null) {
// It's likely this can now never be reached.
// Retained for safety, for now.
return FilterOrErrorMessage.fromError(`do not understand query filter (${this.fieldName()})`);
}

return FilterOrErrorMessage.fromFilter((task: Task) => {
return TextField.maybeNegate(matcher!.matchesAnyOf(task.tags), filterMethod);
});
}

/**
* Returns both forms of the field name, singular and plural.
* @protected
*/
public fieldName(): string {
return 'tag/tags';
export class TagsField extends MultiTextField {
public fieldNameSingular(): string {
return 'tag';
}

protected filterRegexp(): RegExp {
return TagsField.tagRegexp;
public values(task: Task): string[] {
return task.tags;
}
}
45 changes: 31 additions & 14 deletions src/Query/Filter/TextField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { SubstringMatcher } from '../Matchers/SubstringMatcher';
import { RegexMatcher } from '../Matchers/RegexMatcher';
import type { IStringMatcher } from '../Matchers/IStringMatcher';
import { Field } from './Field';
import type { Filter } from './Filter';
import { FilterOrErrorMessage } from './Filter';

/**
Expand All @@ -12,7 +13,7 @@ import { FilterOrErrorMessage } from './Filter';
*/
export abstract class TextField extends Field {
public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
const match = Field.getMatch(this.filterRegexp(), line);
const match = Field.getMatch(this.filterRegExp(), line);
if (match === null) {
// If Field.canCreateFilterForLine() has been checked, we should never get
// in to this block.
Expand All @@ -21,13 +22,12 @@ export abstract class TextField extends Field {

// Construct an IStringMatcher for this filter, or return
// if the inputs are invalid.
const filterMethod = match[1];
const searchString = match[2];
const [_, filterOperator, filterValue] = match;
let matcher: IStringMatcher | null = null;
if (['includes', 'does not include'].includes(filterMethod)) {
matcher = new SubstringMatcher(searchString);
} else if (['regex matches', 'regex does not match'].includes(filterMethod)) {
matcher = RegexMatcher.validateAndConstruct(searchString);
if (filterOperator.includes('include')) {
matcher = new SubstringMatcher(filterValue);
} else if (filterOperator.includes('regex')) {
matcher = RegexMatcher.validateAndConstruct(filterValue);
if (matcher === null) {
return FilterOrErrorMessage.fromError(
`cannot parse regex (${this.fieldName()}); check your leading and trailing slashes for your query`,
Expand All @@ -44,17 +44,31 @@ export abstract class TextField extends Field {
// Finally, we can create the Filter, that takes a task
// and tests if it matches the string filtering rule
// represented by this object.
return FilterOrErrorMessage.fromFilter((task: Task) => {
return TextField.maybeNegate(matcher!.matches(this.value(task)), filterMethod);
});
const negate = filterOperator.match(/not/) !== null;
return FilterOrErrorMessage.fromFilter(this.getFilter(matcher, negate));
}

public static stringIncludesCaseInsensitive(haystack: string, needle: string): boolean {
return SubstringMatcher.stringIncludesCaseInsensitive(haystack, needle);
}

protected filterRegexp(): RegExp {
return new RegExp(`^${this.fieldName()} (includes|does not include|regex matches|regex does not match) (.*)`);
/**
* Returns a regexp pattern matching the field's name and possible aliases
*/
protected fieldPattern(): string {
return this.fieldName();
}

/**
* Returns a regexp pattern matching all possible filter operators for this field,
* such as "includes" or "does not include".
*/
protected filterOperatorPattern(): string {
return 'includes|does not include|regex matches|regex does not match';
}

protected filterRegExp(): RegExp {
return new RegExp(`^(?:${this.fieldPattern()}) (${this.filterOperatorPattern()}) (.*)`);
}

public abstract fieldName(): string;
Expand All @@ -66,7 +80,10 @@ export abstract class TextField extends Field {
*/
public abstract value(task: Task): string;

public static maybeNegate(match: boolean, filterMethod: String) {
return filterMethod.match(/not/) ? !match : match;
protected getFilter(matcher: IStringMatcher, negate: boolean): Filter {
return (task: Task) => {
const match = matcher!.matches(this.value(task));
return negate ? !match : match;
};
}
}

0 comments on commit a5b1cf8

Please sign in to comment.