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); + }); }); });