Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,8 +316,8 @@
"InInventory": "Shows items that you have at least one copy of in your inventory. Only really useful in the Vendors and Records screens.",
"PartialMatch": "Shows items where their name, description, any perk, or any mod has a partial match to the filter text. Search for entire phrases using quotes.",
"PatternUnlocked": "Shows items that have a crafting pattern unlocked, even if the item itself isn't crafted.",
"Perk": "Shows items where one of their perks or mods has a partial match to the filter text in their name or description. Search for entire phrases using quotes.",
"PerkName": "Shows items with a perk or mod whose name matches (exactperk:) or partially matches (perkname:) the filter text. Search for entire phrases using quotes.",
"Perk": "Shows items where one of their perks or mods has a partial match to the filter text in their name or description. Search for entire phrases using quotes. Append +col followed by a column number (e.g. rangefinder+col3) to only match perks in that column, counting the item's visible perk columns from the left; repeat (e.g. +col3+col4) to allow several columns.",
"PerkName": "Shows items with a perk or mod whose name matches (exactperk:) or partially matches (perkname:) the filter text. Search for entire phrases using quotes. Append +col followed by a column number (e.g. rangefinder+col3) to only match perks in that column, counting the item's visible perk columns from the left; repeat (e.g. +col3+col4) to allow several columns.",
"Postmaster": "Items that are currently in the Postmaster.",
"PowerLevel": "Shows items based on their power level. $t(Filter.PowerKeywords)",
"PowerKeywords": "Use the pinnaclecap or softcap keyword instead of a number to refer to the current season's power limits.",
Expand Down
102 changes: 102 additions & 0 deletions src/app/search/__snapshots__/query-parser.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5801,6 +5801,48 @@ exports[`parse |not:maxpower|: lexer 1`] = `
]
`;

exports[`parse |perk:"kill clip"+col3 is:weapon|: ast 1`] = `
{
"length": 31,
"op": "and",
"operands": [
{
"args": "kill clip+col3",
"length": 21,
"op": "filter",
"startIndex": 0,
"type": "perk",
},
{
"args": "weapon",
"length": 9,
"op": "filter",
"startIndex": 22,
"type": "is",
},
],
"startIndex": 0,
}
`;

exports[`parse |perk:"kill clip"+col3 is:weapon|: lexer 1`] = `
[
[
"filter",
"perk",
"kill clip+col3",
],
[
"implicit_and",
],
[
"filter",
"is",
"weapon",
],
]
`;

exports[`parse |perk:"수집가"|: ast 1`] = `
{
"args": "수집가",
Expand Down Expand Up @@ -5841,6 +5883,66 @@ exports[`parse |perk:수집가|: lexer 1`] = `
]
`;

exports[`parse |perkname:"kill clip"+col3+col4|: ast 1`] = `
{
"args": "kill clip+col3+col4",
"length": 30,
"op": "filter",
"startIndex": 0,
"type": "perkname",
}
`;

exports[`parse |perkname:"kill clip"+col3+col4|: lexer 1`] = `
[
[
"filter",
"perkname",
"kill clip+col3+col4",
],
]
`;

exports[`parse |perkname:"kill clip"+col3|: ast 1`] = `
{
"args": "kill clip+col3",
"length": 25,
"op": "filter",
"startIndex": 0,
"type": "perkname",
}
`;

exports[`parse |perkname:"kill clip"+col3|: lexer 1`] = `
[
[
"filter",
"perkname",
"kill clip+col3",
],
]
`;

exports[`parse |perkname:rangefinder+col3|: ast 1`] = `
{
"args": "rangefinder+col3",
"length": 25,
"op": "filter",
"startIndex": 0,
"type": "perkname",
}
`;

exports[`parse |perkname:rangefinder+col3|: lexer 1`] = `
[
[
"filter",
"perkname",
"rangefinder+col3",
],
]
`;

exports[`parse |‘grenade launcher reserves’|: ast 1`] = `
{
"args": "grenade launcher reserves",
Expand Down
11 changes: 11 additions & 0 deletions src/app/search/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ describe('autocompleteTermSuggestions', () => {
['not(', 'Expected failure'],
['memento:', 'memento:any'],
['foo memento:', 'foo memento:any'],
// Perk column selectors: typing `+` right after the value (no space) offers
// `+colN`, whether the value is bare or quoted (the tricky post-quote case).
['perkname:rangefinder+', 'perkname:rangefinder+col1'],
['perkname:rangefinder+co', 'perkname:rangefinder+col1'],
['perkname:"firefly"+', 'perkname:"firefly"+col1'],
['exactperk:"kill clip"+', 'exactperk:"kill clip"+col1'],
['perkname:rangefinder+col3+', 'perkname:rangefinder+col3+col1'],
['is:weapon perkname:"firefly"+| -is:exotic', 'is:weapon perkname:"firefly"+col1 -is:exotic'],
// The `+` multiquery suggestions for non-perk filters (e.g. dupe) must NOT be
// hijacked by the perk-column path.
['dupe:tag+', 'dupe:item'],
];

const plainStringCases: [query: string, mockCandidate: string][] = [['jotu', 'jötunn']];
Expand Down
64 changes: 64 additions & 0 deletions src/app/search/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ export type SearchItem =
/** matches a keyword that's probably a math comparison, but not with a value on the RHS */
const mathCheck = /[\d<>=]$/;

/**
* The number of perk columns we offer to complete. Weapons top out at five
* meaningful perk columns (barrel, magazine, trait 1, trait 2, origin trait).
*/
const perkColumnCount = 5;

/**
* Matches a perk filter whose value is already complete - a quoted string or a
* bareword - directly followed by a partially typed `+colN` selector, with no
* space in between. The capturing group is the part to keep (filter, value, and
* any complete `+colN` selectors already present); the trailing group is the
* partial selector to replace. This lets `perkname:"firefly"+` (or
* `perkname:rangefinder+co`) suggest the next `+colN` term even though there's no
* space separating it from the value.
*/
const perkColumnCompletion =
/((?:perk|perkname|exactperk):(?:"[^"]*"|'[^']*'|[^\s()"'+]+)(?:\+col\d+)*)\+(?:col\d*|co|c)?$/i;

/** if one of these has been typed, stop guessing which filter and just offer this filter's values */
// TODO: Generate this from the search config
const filterNames = [
Expand Down Expand Up @@ -313,6 +331,15 @@ export function autocompleteTermSuggestions<I, FilterCtx, SuggestionsCtx>(
caretIndex = (caretEndRegex.exec(query.slice(caretIndex))?.index || 0) + caretIndex;

const queryUpToCaret = query.slice(0, caretIndex);

// A `+` typed right after a perk filter's value starts a `+colN` column
// selector attached to that value (no separating space), so offer those
// completions before the normal per-token logic runs.
const columnSuggestions = perkColumnCompletions(query, queryUpToCaret, caretIndex);
if (columnSuggestions.length) {
return columnSuggestions;
}

const lastFilters = findLastFilter(queryUpToCaret);
if (!lastFilters) {
return [];
Expand Down Expand Up @@ -351,6 +378,43 @@ export function autocompleteTermSuggestions<I, FilterCtx, SuggestionsCtx>(
return [];
}

/**
* Offer `+col1`..`+colN` completions when the caret sits right after a perk
* filter's value and a partial `+colN` selector (e.g. `perkname:"firefly"+` or
* `perkname:rangefinder+co`). Returns an empty array when the query doesn't end
* that way, so normal suggestions take over.
*/
function perkColumnCompletions(
query: string,
queryUpToCaret: string,
caretIndex: number,
): SearchItem[] {
const match = perkColumnCompletion.exec(queryUpToCaret);
if (!match) {
return [];
}
// Everything up to (but not including) the partial selector we're replacing.
const base = queryUpToCaret.slice(0, match.index) + match[1];
const rest = query.slice(caretIndex);
const suggestions: SearchItem[] = [];
for (let col = 1; col <= perkColumnCount; col++) {
const insert = `+col${col}`;
const body = base + insert + rest;
suggestions.push({
query: {
fullText: body,
body,
},
type: SearchItemType.Autocomplete,
highlightRange: {
section: 'body',
range: [base.length, base.length + insert.length],
},
});
}
return suggestions;
}

function findFilter<I, FilterCtx, SuggestionsCtx>(
term: string,
filtersMap: FiltersMap<I, FilterCtx, SuggestionsCtx>,
Expand Down
112 changes: 112 additions & 0 deletions src/app/search/items/search-filters/freeform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { DimItem } from 'app/inventory/item-types';
import { getDisplayedItemSockets, getSocketsByIndexes } from 'app/utils/socket-utils';
import { getTestDefinitions, getTestStores, setupi18n } from 'testing/test-utils';
import { ItemFilter } from '../../filter-types';
import { makeSearchFilterFactory } from '../../search-filter';
import { FilterContext } from '../item-filter-types';
import { buildItemSearchConfig } from '../item-search-filter';
import { parsePerkColumns } from './freeform';

describe('parsePerkColumns', () => {
const cases: [input: string, text: string, columns: number[] | undefined][] = [
// No +col tokens: passes the value through unchanged
['rangefinder', 'rangefinder', undefined],
// A single column
['rangefinder+col3', 'rangefinder', [3]],
// Multiple columns keep left-to-right order regardless of how they're typed
['rangefinder+col3+col4', 'rangefinder', [3, 4]],
['rangefinder+col4+col3', 'rangefinder', [4, 3]],
// Multi-digit column numbers
['frenzy+col12', 'frenzy', [12]],
// Only trailing +colN tokens are consumed; a literal '+' in the perk text is left alone
['a+b', 'a+b', undefined],
['a+b+col3', 'a+b', [3]],
// '+col' followed by non-digits (or not anchored at the end) is treated as text
['col3', 'col3', undefined],
['rangefinder+column3', 'rangefinder+column3', undefined],
];

test.each(cases)('parsePerkColumns(%p) -> text %p, columns %p', (input, text, columns) => {
expect(parsePerkColumns(input)).toEqual({ text, columns });
});
});

describe('perk column filtering', () => {
let items: DimItem[];
let makeFilter: (query: string) => ItemFilter;

beforeAll(async () => {
await setupi18n();
const [defs, stores] = await Promise.all([getTestDefinitions(), getTestStores()]);
items = stores.flatMap((s) => s.items);
const config = buildItemSearchConfig(2, 'en', {});
const filterContext = { language: 'en', d2Definitions: defs } as unknown as FilterContext;
makeFilter = makeSearchFilterFactory(config, filterContext);
});

/** Report which 1-based perk column a matching perk sits in, or undefined. */
const perkColumnOf = (item: DimItem, test: (name: string) => boolean): number | undefined => {
const perks = getDisplayedItemSockets(item, /* excludeEmptySockets */ true)?.perks;
if (!perks || !item.sockets) {
return undefined;
}
const perkSockets = getSocketsByIndexes(item.sockets, perks.socketIndexes);
const idx = perkSockets.findIndex((s) =>
s.plugOptions.some((p) => test(p.plugDef.displayProperties.name)),
);
return idx === -1 ? undefined : idx + 1;
};

const hasRangefinder = (name: string) => name.toLowerCase() === 'rangefinder';

test('a +colN query is a subset of the plain query', () => {
const plain = items.filter(makeFilter('perkname:rangefinder'));
const col3 = items.filter(makeFilter('perkname:rangefinder+col3'));
expect(plain.length).toBeGreaterThan(0);
expect(col3.length).toBeGreaterThan(0);
expect(col3.every((i) => plain.includes(i))).toBe(true);
});

test('columns partition the matches and multiple +colN tokens union them', () => {
const plainTrait = items.filter(makeFilter('perkname:rangefinder+col3+col4'));
const col3 = new Set(items.filter(makeFilter('perkname:rangefinder+col3')));
const col4 = new Set(items.filter(makeFilter('perkname:rangefinder+col4')));
// The two trait columns don't overlap...
expect([...col3].some((i) => col4.has(i))).toBe(false);
// ...and +col3+col4 is exactly their union.
expect(new Set(plainTrait)).toEqual(new Set([...col3, ...col4]));
});

test('every +col3 match actually has the perk in its 3rd perk column', () => {
const col3 = items.filter(makeFilter('perkname:rangefinder+col3'));
expect(col3.length).toBeGreaterThan(0);
for (const item of col3) {
expect(perkColumnOf(item, hasRangefinder)).toBe(3);
}
});

test('a barrel-only perk lands in column 1, never in a trait column', () => {
const col1 = items.filter(makeFilter('perkname:"arrowhead brake"+col1'));
const col3 = items.filter(makeFilter('perkname:"arrowhead brake"+col3'));
expect(col1.length).toBeGreaterThan(0);
expect(col3.length).toBe(0);
});

test('quoting the perk name keeps the +colN selector attached', () => {
// Regression: a trailing +colN sits outside the closing quote, so it used to
// be dropped and the column was ignored for quoted perk names.
const unquoted = new Set(items.filter(makeFilter('perkname:rangefinder+col3')));
const quoted = new Set(items.filter(makeFilter('perkname:"rangefinder"+col3')));
expect(quoted.size).toBeGreaterThan(0);
expect(quoted).toEqual(unquoted);
// And it still restricts by column rather than matching every rangefinder.
const plain = items.filter(makeFilter('perkname:"rangefinder"'));
expect(quoted.size).toBeLessThan(plain.length);
});

test('a perk in no requested column matches nothing', () => {
// Rangefinder is a trait, so it never appears in the barrel/magazine columns.
expect(items.filter(makeFilter('perkname:rangefinder+col1')).length).toBe(0);
expect(items.filter(makeFilter('perkname:rangefinder+col2')).length).toBe(0);
});
});
Loading