diff --git a/packages/xod-client/src/editor/components/Suggester.jsx b/packages/xod-client/src/editor/components/Suggester.jsx
index 363e1396..dfe76a6b 100644
--- a/packages/xod-client/src/editor/components/Suggester.jsx
+++ b/packages/xod-client/src/editor/components/Suggester.jsx
@@ -146,9 +146,28 @@ class Suggester extends React.Component {
const cls = classNames('Suggester-item', {
'is-highlighted': isHighlighted,
});
- const value = regExpEscape(this.state.value);
const { item } = suggestion;
+ // TODO: Move extracting words for highlighter
+ // into `xod-patch-search` as `matched` property
+ // for each result
+ const searchWords = R.compose(
+ R.map(regExpEscape),
+ R.ifElse(
+ R.contains('('),
+ R.compose(
+ R.reject(R.isEmpty),
+ R.unless(
+ R.compose(R.isEmpty, R.nth(1)),
+ R.compose(R.unnest, R.over(R.lensIndex(1), R.split(',')))
+ ),
+ R.slice(1, 3),
+ R.match(/^(.+)\(([a-z0-9-,]*)\){0,1}$/)
+ ),
+ R.of
+ )
+ )(this.state.value);
+
return (
diff --git a/packages/xod-patch-search/package.json b/packages/xod-patch-search/package.json
index 93ed2dfd..6fb179fd 100644
--- a/packages/xod-patch-search/package.json
+++ b/packages/xod-patch-search/package.json
@@ -21,6 +21,7 @@
"dependencies": {
"fuse.js": "^3.0.5",
"ramda": "^0.24.1",
+ "ramda-fantasy": "^0.8.0",
"xod-func-tools": "^0.19.2",
"xod-project": "^0.19.2"
},
diff --git a/packages/xod-patch-search/src/index.js b/packages/xod-patch-search/src/index.js
index 0e419b32..2e9e1ec1 100644
--- a/packages/xod-patch-search/src/index.js
+++ b/packages/xod-patch-search/src/index.js
@@ -1,6 +1,8 @@
import * as R from 'ramda';
import Fuse from 'fuse.js';
+import { Maybe } from 'ramda-fantasy';
import { getBaseName } from 'xod-project';
+import { foldMaybe } from 'xod-func-tools';
import createIndexData from './mapper';
@@ -59,7 +61,7 @@ const calculateEntryRate = (str, token) => {
// If str ends with token
if (str.length === index + token.length) return 0.5;
// If str doesn't contain token
- if (index !== -1) return 3;
+ if (index === -1) return 3;
// If str contains token
return 2.5;
};
@@ -90,6 +92,22 @@ const refineScore = R.curry((query, result) =>
)(result)
);
+const refineScoreForSpecializationNode = R.curry((query, result) =>
+ R.compose(
+ sortByScoreOrAlphabetically,
+ R.map(item => {
+ const tokens = query.trim().split(',');
+ const tokensInItem = R.filter(R.contains(R.__, item.item.path), tokens);
+ const tokensInItemCount = tokensInItem.length;
+
+ const newScore =
+ tokensInItemCount > 0 ? item.score / tokensInItemCount : Infinity;
+
+ return R.assoc('score', newScore, item);
+ })
+ )(result)
+);
+
const getAverageScore = resultList =>
R.compose(
R.divide(R.__, resultList.length),
@@ -97,7 +115,10 @@ const getAverageScore = resultList =>
R.pluck('score')
)(resultList);
-const reduceResults = results => {
+const reduceResults = rawResults => {
+ // Cut results with "Infinity" score from results
+ const results = R.reject(R.propEq('score', Infinity), rawResults);
+
const averageScore = getAverageScore(results);
const scoreToFilter = Math.min(averageScore * 2, 0.3);
@@ -109,13 +130,41 @@ const reduceResults = results => {
return results;
};
+const itemPathContainsOpeningBracket = R.pathSatisfies(R.contains('('), [
+ 'item',
+ 'path',
+]);
+
+const filterSpecializationNodes = R.curry((query, idx) =>
+ R.ifElse(
+ R.contains('('),
+ R.compose(
+ R.reject(R.complement(itemPathContainsOpeningBracket)),
+ foldMaybe(idx, refineScoreForSpecializationNode(R.__, idx)),
+ Maybe,
+ R.nth(1),
+ R.match(/\((.+)\){0,1}$/)
+ ),
+ () => R.reject(itemPathContainsOpeningBracket)(idx)
+ )(query)
+);
+
+// :: String -> String
+const takeQueryWithoutBrackets = R.compose(R.nth(0), R.split('('));
+
// :: [IndexData] -> SearchIndex
export const createIndex = data => {
const idx = new Fuse(data, options);
return {
search: query =>
- R.compose(reduceResults, refineScore(query), idx.search.bind(idx))(query),
+ R.compose(
+ reduceResults,
+ filterSpecializationNodes(query),
+ refineScore(query),
+ idx.search.bind(idx),
+ takeQueryWithoutBrackets
+ )(query),
};
};
// :: [Patch] -> SearchIndex
diff --git a/packages/xod-patch-search/test/fixtures/abstract-and-specializations.xodball b/packages/xod-patch-search/test/fixtures/abstract-and-specializations.xodball
new file mode 100644
index 00000000..c209a335
--- /dev/null
+++ b/packages/xod-patch-search/test/fixtures/abstract-and-specializations.xodball
@@ -0,0 +1,159 @@
+{
+ "name": "",
+ "patches": {
+ "@/main": {
+ "nodes": {
+ "SyY2S20KG": {
+ "id": "SyY2S20KG",
+ "position": {
+ "x": 102,
+ "y": -204
+ },
+ "type": "@/when-either-changes(number,string)"
+ },
+ "r18hH2RtM": {
+ "id": "r18hH2RtM",
+ "position": {
+ "x": -34,
+ "y": -204
+ },
+ "type": "@/when-either-changes(number,boolean)"
+ },
+ "rkNnr2RKf": {
+ "id": "rkNnr2RKf",
+ "position": {
+ "x": -170,
+ "y": -204
+ },
+ "type": "@/when-either-changes"
+ }
+ },
+ "path": "@/main"
+ },
+ "@/when-either-changes": {
+ "nodes": {
+ "B1x_SnRtG": {
+ "id": "B1x_SnRtG",
+ "position": {
+ "x": 170,
+ "y": 0
+ },
+ "type": "xod/patch-nodes/input-t2"
+ },
+ "ByE4B20Yf": {
+ "id": "ByE4B20Yf",
+ "position": {
+ "x": 136,
+ "y": 0
+ },
+ "type": "xod/patch-nodes/input-t1"
+ },
+ "SytmH2RFG": {
+ "id": "SytmH2RFG",
+ "position": {
+ "x": 136,
+ "y": 102
+ },
+ "type": "xod/patch-nodes/abstract"
+ },
+ "ryuwSh0Fz": {
+ "id": "ryuwSh0Fz",
+ "position": {
+ "x": 136,
+ "y": 204
+ },
+ "type": "xod/patch-nodes/output-pulse"
+ }
+ },
+ "path": "@/when-either-changes"
+ },
+ "@/when-either-changes(number,boolean)": {
+ "attachments": [
+ {
+ "content": "\nstruct State {\n};\n\n{{ GENERATED_CODE }}\n\nvoid evaluate(Context ctx) {\n //auto inValue = getValue(ctx);\n //emitValue(ctx, inValue);\n}\n",
+ "encoding": "utf-8",
+ "filename": "patch.cpp"
+ }
+ ],
+ "nodes": {
+ "ByY5H2RFf": {
+ "id": "ByY5H2RFf",
+ "position": {
+ "x": 68,
+ "y": 0
+ },
+ "type": "xod/patch-nodes/input-number"
+ },
+ "HJ7or3Atz": {
+ "id": "HJ7or3Atz",
+ "position": {
+ "x": 68,
+ "y": 204
+ },
+ "type": "xod/patch-nodes/output-pulse"
+ },
+ "SkgsBh0Yf": {
+ "id": "SkgsBh0Yf",
+ "position": {
+ "x": 68,
+ "y": 102
+ },
+ "type": "xod/patch-nodes/not-implemented-in-xod"
+ },
+ "rJ2qSn0Fz": {
+ "id": "rJ2qSn0Fz",
+ "position": {
+ "x": 102,
+ "y": 0
+ },
+ "type": "xod/patch-nodes/input-boolean"
+ }
+ },
+ "path": "@/when-either-changes(number,boolean)"
+ },
+ "@/when-either-changes(number,string)": {
+ "attachments": [
+ {
+ "content": "\nstruct State {\n};\n\n{{ GENERATED_CODE }}\n\nvoid evaluate(Context ctx) {\n //auto inValue = getValue(ctx);\n //emitValue(ctx, inValue);\n}\n",
+ "encoding": "utf-8",
+ "filename": "patch.cpp"
+ }
+ ],
+ "nodes": {
+ "BJFYS20Yz": {
+ "id": "BJFYS20Yz",
+ "position": {
+ "x": 68,
+ "y": 204
+ },
+ "type": "xod/patch-nodes/output-pulse"
+ },
+ "HJecrnAKG": {
+ "id": "HJecrnAKG",
+ "position": {
+ "x": 68,
+ "y": 102
+ },
+ "type": "xod/patch-nodes/not-implemented-in-xod"
+ },
+ "S1WYr30tM": {
+ "id": "S1WYr30tM",
+ "position": {
+ "x": 68,
+ "y": 0
+ },
+ "type": "xod/patch-nodes/input-number"
+ },
+ "SJrFBnCtf": {
+ "id": "SJrFBnCtf",
+ "position": {
+ "x": 102,
+ "y": 0
+ },
+ "type": "xod/patch-nodes/input-string"
+ }
+ },
+ "path": "@/when-either-changes(number,string)"
+ }
+ }
+}
diff --git a/packages/xod-patch-search/test/index.spec.js b/packages/xod-patch-search/test/index.spec.js
index f5a0520b..f1bd8d08 100644
--- a/packages/xod-patch-search/test/index.spec.js
+++ b/packages/xod-patch-search/test/index.spec.js
@@ -6,85 +6,177 @@ import { loadProject } from 'xod-fs';
import { createIndex, createIndexData } from '../src/index';
+const workspace = path.resolve(__dirname, '../../../workspace');
+const getProjectPath = projectName => path.resolve(workspace, projectName);
+const fixture = p => path.resolve(__dirname, './fixtures/', p);
+
describe('xod-patch-search/index', () => {
- let indexData = [];
- let idx = {};
- const workspace = path.resolve(__dirname, '../../../workspace');
- const getProjectPath = projectName => path.resolve(workspace, projectName);
+ describe('general search', () => {
+ let indexData = [];
+ let idx = {};
+ before(() =>
+ loadProject([workspace], getProjectPath('welcome-to-xod'))
+ .then(listPatches)
+ .then(createIndexData)
+ .then(iData => {
+ indexData = iData;
+ idx = createIndex(indexData);
+ })
+ );
- before(() =>
- loadProject([workspace], getProjectPath('welcome-to-xod'))
- .then(listPatches)
- .then(createIndexData)
- .then(iData => {
- indexData = iData;
- idx = createIndex(indexData);
- })
- );
+ it('searches by path correctly', () => {
+ const result = idx.search('path:number');
+ assert.equal(result[0].item.path, 'xod/core/nth-number');
+ });
- it('searches by path correctly', () => {
- const result = idx.search('path:number');
- assert.equal(result[0].item.path, 'xod/core/nth-number');
+ it('searches by lib correctly', () => {
+ const result = idx.search('lib:xod/core');
+ const expectedLength = R.compose(
+ R.length,
+ // We have to reject patches with path contains "(",
+ // cause we're not showing specialization nodes if
+ // query does not contain "(" symbol
+ R.reject(R.propSatisfies(R.contains('('), 'path')),
+ R.filter(R.propEq('lib', 'xod/core'))
+ )(indexData);
+ assert.lengthOf(result, expectedLength);
+ });
+
+ it('searches: "number"', () =>
+ assert.equal(
+ idx.search('number')[0].item.path,
+ 'xod/core/nth-number' // Cause it has a `number` in the path and it alphabetically sorted (that's why not *-to-string)
+ ));
+
+ it('searches: "lib:xod/patch-nodes number"', () => {
+ assert.equal(
+ idx.search('lib:xod/patch-nodes number')[0].item.path,
+ 'xod/patch-nodes/input-number' // The same as above + filtered by lib
+ );
+ });
+
+ it('searches: "meter"', () => {
+ const results = idx.search('meter');
+ const foundPatchPaths = R.map(R.path(['item', 'path']), results);
+ assert.equal(
+ results[0].item.path,
+ 'xod/common-hardware/gp2y0a02-range-meter' // Cause this node has a full match `meter` in the `path`
+ );
+ assert.includeMembers(foundPatchPaths, ['xod/units/m-to-ft']); // Don't care about position, but be sure that it's found
+ });
+
+ it('searches: "temp"', () =>
+ assert.equal(
+ idx.search('temp')[0].item.path,
+ 'xod/units/c-to-f' // Cause this node has a `temperature` in the description
+ ));
+
+ it('searches: "therm"', () => {
+ const results = idx.search('therm');
+ assert.equal(
+ results[0].item.path,
+ 'xod/common-hardware/thermometer-tmp36' // Cause this node has a `thermometer` in the path and it alphabetically sorted
+ );
+ assert.equal(
+ results[1].item.path,
+ 'xod/common-hardware/dht11-thermometer' // Cause this node has a `thermometer` in the path and it alphabetically sorted
+ );
+ });
+
+ it('searches: "ult"', () => {
+ const results = idx.search('ult');
+ assert.equal(
+ results[0].item.path,
+ 'xod/common-hardware/hc-sr04-ultrasonic-range'
+ );
+ assert.equal(
+ results[1].item.path,
+ 'xod/common-hardware/hc-sr04-ultrasonic-time'
+ );
+ assert.equal(results[2].item.path, 'xod/core/multiply');
+ });
});
- it('searches by lib correctly', () => {
- const result = idx.search('lib:xod/core');
- const expectedLength = indexData.filter(item => item.lib === 'xod/core')
- .length;
- assert.lengthOf(result, expectedLength);
- });
-
- it('searches: "number"', () =>
- assert.equal(
- idx.search('number')[0].item.path,
- 'xod/core/nth-number' // Cause it has a `number` in the path and it alphabetically sorted (that's why not *-to-string)
- ));
-
- it('searches: "lib:xod/patch-nodes number"', () => {
- assert.equal(
- idx.search('lib:xod/patch-nodes number')[0].item.path,
- 'xod/patch-nodes/input-number' // The same as above + filtered by lib
+ describe('abstract nodes and specializations', () => {
+ let indexData = [];
+ let idx = {};
+ before(() =>
+ loadProject([workspace], fixture('abstract-and-specializations.xodball'))
+ .then(listPatches)
+ .then(createIndexData)
+ .then(iData => {
+ indexData = iData;
+ idx = createIndex(indexData);
+ })
);
- });
+ it('searches abstract node "when-either-ch" and doesn\'t show specialization nodes', () => {
+ const results = idx.search('when-either-ch');
+ const patchPaths = R.map(R.path(['item', 'path']), results);
- it('searches: "meter"', () => {
- const results = idx.search('meter');
- const foundPatchPaths = R.map(R.path(['item', 'path']), results);
- assert.equal(
- results[0].item.path,
- 'xod/common-hardware/gp2y0a02-range-meter' // Cause this node has a full match `meter` in the `path`
- );
- assert.includeMembers(foundPatchPaths, ['xod/units/m-to-ft']); // Don't care about position, but be sure that it's found
- });
+ assert.equal(results[0].item.path, '@/when-either-changes');
+ assert.isFalse(
+ R.contains('@/when-either-changes(number,string)', patchPaths)
+ );
+ assert.isFalse(
+ R.contains('@/when-either-changes(number,boolean)', patchPaths)
+ );
+ });
+ it('searches all specialization nodes for "when-either-ch("', () => {
+ const results = idx.search('when-either-ch(');
+ const patchPaths = R.map(R.path(['item', 'path']), results);
- it('searches: "temp"', () =>
- assert.equal(
- idx.search('temp')[0].item.path,
- 'xod/units/c-to-f' // Cause this node has a `temperature` in the description
- ));
+ assert.isFalse(R.contains('@/when-either-changes', patchPaths));
+ assert.isTrue(
+ R.contains('@/when-either-changes(number,string)', patchPaths)
+ );
+ assert.isTrue(
+ R.contains('@/when-either-changes(number,boolean)', patchPaths)
+ );
+ });
+ it('searches both specialization nodes for "when-either-ch(mber"', () => {
+ const results = idx.search('when-either-ch(mber');
+ const patchPaths = R.map(R.path(['item', 'path']), results);
+ const specializations = R.take(2, patchPaths);
- it('searches: "therm"', () => {
- const results = idx.search('therm');
- assert.equal(
- results[0].item.path,
- 'xod/common-hardware/thermometer-tmp36' // Cause this node has a `thermometer` in the path and it alphabetically sorted
- );
- assert.equal(
- results[1].item.path,
- 'xod/common-hardware/dht11-thermometer' // Cause this node has a `thermometer` in the path and it alphabetically sorted
- );
- });
+ assert.isFalse(R.contains('@/when-either-changes', patchPaths));
+ assert.sameMembers(
+ [
+ '@/when-either-changes(number,string)',
+ '@/when-either-changes(number,boolean)',
+ ],
+ specializations
+ );
+ });
+ it('searches one specialization node for "when-(tr"', () => {
+ const results = idx.search('when-(tr');
- it('searches: "ult"', () => {
- const results = idx.search('ult');
- assert.equal(
- results[0].item.path,
- 'xod/common-hardware/hc-sr04-ultrasonic-range'
- );
- assert.equal(
- results[1].item.path,
- 'xod/common-hardware/hc-sr04-ultrasonic-time'
- );
- assert.equal(results[2].item.path, 'xod/core/multiply');
+ assert.equal(
+ results[0].item.path,
+ '@/when-either-changes(number,string)'
+ );
+ });
+ it('searches one specialization node for "when-either-ch(ring"', () => {
+ const results = idx.search('when-either-ch(ring');
+ const patchPaths = R.map(R.path(['item', 'path']), results);
+
+ assert.equal(
+ results[0].item.path,
+ '@/when-either-changes(number,string)'
+ );
+ assert.isFalse(R.contains('@/when-either-changes', patchPaths));
+ });
+ it('searches one specialization node for "when-either-ch(number,boolean)"', () => {
+ const results = idx.search('when-either-ch(number,boolean)');
+
+ assert.equal(
+ results[0].item.path,
+ '@/when-either-changes(number,boolean)'
+ );
+ });
+ it('searches no specialization node for "when-either-ch(pulse)"', () => {
+ const results = idx.search('when-either-ch(pulse)');
+
+ assert.lengthOf(results, 0);
+ });
});
});