@neo4j-cypher/language-server 2.0.0-next.39 → 2.0.0-next.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neo4j-cypher/language-server",
3
- "version": "2.0.0-next.39",
3
+ "version": "2.0.0-next.40",
4
4
  "description": "Cypher Language Server",
5
5
  "keywords": [
6
6
  "cypher",
@@ -34,9 +34,9 @@
34
34
  "vscode-languageserver": "^8.1.0",
35
35
  "vscode-languageserver-textdocument": "^1.0.8",
36
36
  "workerpool": "^9.3.3",
37
- "@neo4j-cypher/language-support": "2.0.0-next.38",
38
- "@neo4j-cypher/query-tools": "2.0.0-next.38",
39
- "@neo4j-cypher/lint-worker": "1.10.1-next.15"
37
+ "@neo4j-cypher/lint-worker": "1.10.1-next.16",
38
+ "@neo4j-cypher/language-support": "2.0.0-next.39",
39
+ "@neo4j-cypher/query-tools": "2.0.0-next.39"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/lodash.debounce": "^4.0.9",
@@ -50,6 +50,7 @@
50
50
  "copy-lint-worker": "copyfiles -u 4 ../lint-worker/dist/cjs/lintWorker.cjs dist/",
51
51
  "bundle": "esbuild ./src/server.ts --bundle --format=cjs --platform=node --outfile=dist/cypher-language-server.js --minify --conditions=require",
52
52
  "dev": "tsc --watch",
53
+ "test": "vitest run",
53
54
  "make-executable": "cd dist && echo '#!/usr/bin/env node' > cypher-language-server && cat cypher-language-server.js >> cypher-language-server",
54
55
  "clean": "rm -rf dist tsconfig*.tsbuildinfo"
55
56
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Descriptions in hover info are rendered as markdown in VS Code, which eats
3
+ * the wildcard '*' usages some of them have, as in dbms.queryJmx' "(e.g.
4
+ * '*:*,name=*neo4j*' for all metrics)", so we escape those. Not the asterisks a
5
+ * description means as markup though - in a code span, or as a bullet - where
6
+ * the backslash would show up instead. Backslashes are escaped along with them.
7
+ *
8
+ * Some descriptions are map-style objects (often configs) rather than prose.
9
+ * Markdown would put those on one line, so we tag them as 'text' code blocks
10
+ * and let their own layout through.
11
+ */
12
+
13
+ const bulletMarker = '* ';
14
+
15
+ /* A code span, or the prose up to the next backtick. Pairing left to right, as
16
+ markdown does, leaves the stray backtick a few descriptions have
17
+ (apoc.nodes.rels', apoc.path.expandConfig') as prose. */
18
+ const codeSpanOrProse = /(`[^`]*`)|([^`]+|`)/g;
19
+
20
+ export function escapeDescriptionForMarkdown(description: string): string {
21
+ // Line by line, so that an unclosed backtick cannot reach the lines below
22
+ return description.split('\n').map(escapeLine).join('\n');
23
+ }
24
+
25
+ function escapeLine(line: string): string {
26
+ const bullet = line.startsWith(bulletMarker) ? bulletMarker : '';
27
+
28
+ return (
29
+ bullet +
30
+ line.slice(bullet.length).replace(
31
+ codeSpanOrProse,
32
+ /* Escaping inside a code span would show the backslash, and descriptions
33
+ put wildcards there too, e.g. 'use `*:*` to find all JMX beans'. */
34
+ (_matched, codeSpan: string | undefined, prose: string) =>
35
+ codeSpan ?? prose.replace(/[\\*]/g, '\\$&'),
36
+ )
37
+ );
38
+ }
39
+
40
+ /* For example a map-style description, laid out over several lines */
41
+ function hasInnerNewlines(description: string): boolean {
42
+ const lines = description.trimEnd().split('\n');
43
+ return lines.length > 1;
44
+ }
45
+
46
+ export function formatLineForMarkdown(
47
+ name: string,
48
+ description: string,
49
+ ): string[] {
50
+ const bullet = `- \`${name}\` -`;
51
+
52
+ return hasInnerNewlines(description)
53
+ ? [bullet, '```text', description.trimEnd(), '```']
54
+ : [`${bullet} ${escapeLine(description)}`];
55
+ }
package/src/hoverInfo.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { Hover, HoverParams, TextDocuments } from 'vscode-languageserver';
2
+ import { MarkupKind } from 'vscode-languageserver';
2
3
  import type { TextDocument } from 'vscode-languageserver-textdocument';
3
4
  import { languageService } from './server.js';
4
5
  import type { Neo4jSchemaPoller } from '@neo4j-cypher/query-tools';
6
+ import { hoverInfoToMarkdown } from './hoverMarkdown.js';
5
7
 
6
8
  export function doHoverInfo(
7
9
  documents: TextDocuments<TextDocument>,
@@ -16,5 +18,12 @@ export function doHoverInfo(
16
18
  caretPosition: offset,
17
19
  dbSchema: neo4jSchemaPoller.metadata?.dbSchema ?? {},
18
20
  });
19
- return hoverInfo ? hoverInfo : null;
21
+ if (!hoverInfo) return null;
22
+
23
+ return {
24
+ contents: {
25
+ kind: MarkupKind.Markdown,
26
+ value: hoverInfoToMarkdown(hoverInfo),
27
+ },
28
+ };
20
29
  }
@@ -0,0 +1,56 @@
1
+ import type {
2
+ HoverDescription,
3
+ HoverInfo,
4
+ } from '@neo4j-cypher/language-support';
5
+ import {
6
+ escapeDescriptionForMarkdown,
7
+ formatLineForMarkdown,
8
+ } from './descriptionToMarkdown.js';
9
+
10
+ function cypherCodeBlock(fragment: string): string[] {
11
+ return ['```cypher', fragment, '```'];
12
+ }
13
+
14
+ export function hoverInfoToMarkdown(info: HoverInfo): string {
15
+ if (info.kind === 'variable') {
16
+ const typeLine = ['`', `${info.variable}: ${info.types.join(', ')}`, '`'];
17
+ return (
18
+ info.labelExpression === undefined
19
+ ? typeLine
20
+ : [...typeLine, '', ...cypherCodeBlock(info.labelExpression)]
21
+ ).join('\n');
22
+ }
23
+
24
+ return [
25
+ ...cypherCodeBlock(info.signature),
26
+ `${info.isDeprecated ? '(_deprecated_) ' : ''}${escapeDescriptionForMarkdown(
27
+ info.description,
28
+ )}`,
29
+ '',
30
+ ...descriptionsToMarkdown('**Parameters**', info.parameters),
31
+ '',
32
+ ...(info.kind === 'function'
33
+ ? functionReturn(info.returnType)
34
+ : descriptionsToMarkdown('**Returns**', info.returnValues)),
35
+ ].join('\n');
36
+ }
37
+
38
+ function descriptionsToMarkdown(
39
+ heading: string,
40
+ descriptions: HoverDescription[],
41
+ ): string[] {
42
+ if (descriptions.length === 0) {
43
+ return [];
44
+ }
45
+
46
+ return [
47
+ heading,
48
+ ...descriptions.flatMap(({ name, description }) =>
49
+ formatLineForMarkdown(name, description),
50
+ ),
51
+ ];
52
+ }
53
+
54
+ function functionReturn(returnType: string): string[] {
55
+ return returnType ? [`**Returns:** \`${returnType}\``] : [];
56
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ formatLineForMarkdown,
4
+ escapeDescriptionForMarkdown,
5
+ } from '../descriptionToMarkdown.js';
6
+
7
+ /* Uses descriptions from real procedures and
8
+ functions named in the test titles */
9
+
10
+ describe('escapes the wildcards a description writes as prose', () => {
11
+ test('dbms.queryJmxs query parameter', () => {
12
+ expect(
13
+ escapeDescriptionForMarkdown(
14
+ "A query for MBeans on this MBeanServer (e.g. '*:*,name=*neo4j*' for all metrics in neo4j database).",
15
+ ),
16
+ ).toBe(
17
+ "A query for MBeans on this MBeanServer (e.g. '\\*:\\*,name=\\*neo4j\\*' for all metrics in neo4j database).",
18
+ );
19
+ });
20
+
21
+ test('apoc.math.sigmoidPrime description', () => {
22
+ expect(
23
+ escapeDescriptionForMarkdown(
24
+ 'Returns the sigmoid prime [ sigmoid(val) * (1 - sigmoid(val)) ] of the given value.',
25
+ ),
26
+ ).toBe(
27
+ 'Returns the sigmoid prime [ sigmoid(val) \\* (1 - sigmoid(val)) ] of the given value.',
28
+ );
29
+ });
30
+
31
+ test('apoc.algo.aStar description', () => {
32
+ expect(escapeDescriptionForMarkdown('Runs the A* search algorithm.')).toBe(
33
+ 'Runs the A\\* search algorithm.',
34
+ );
35
+ });
36
+ });
37
+
38
+ describe('leaves the wildcards inside a code span alone', () => {
39
+ test('dbms.queryJmx description', () => {
40
+ const description =
41
+ 'Query JMX management data by domain and name. For instance, use `*:*` to find all JMX beans.';
42
+
43
+ expect(escapeDescriptionForMarkdown(description)).toBe(description);
44
+ });
45
+
46
+ test('gds.graph.nodeProperties.streams nodeLabels parameter', () => {
47
+ const listed =
48
+ "The node labels to aggregate over. Use `['*']` to indicate all.";
49
+
50
+ expect(escapeDescriptionForMarkdown(listed)).toBe(listed);
51
+ });
52
+ });
53
+
54
+ describe('escaping descriptions', () => {
55
+ test('escapes the backslashes, so that the escaping can be undone', () => {
56
+ expect(escapeDescriptionForMarkdown('a \\ b')).toBe('a \\\\ b');
57
+ expect(escapeDescriptionForMarkdown('already \\* escaped')).toBe(
58
+ 'already \\\\\\* escaped',
59
+ );
60
+ });
61
+
62
+ test('leaves the underscores of a description alone - apoc.json.path pathOptions parameter', () => {
63
+ const values =
64
+ "JSON path options: ('ALWAYS_RETURN_LIST', 'DEFAULT_PATH_LEAF_TO_NULL').";
65
+
66
+ expect(escapeDescriptionForMarkdown(values)).toBe(values);
67
+ });
68
+
69
+ test('leaves the underscores of a description alone - db.index.fulltext.queryNodes', () => {
70
+ const italics = 'Valid _key: value_ pairs for the `options` map are:';
71
+
72
+ expect(escapeDescriptionForMarkdown(italics)).toBe(italics);
73
+ });
74
+
75
+ test('leaves a backtick that is never closed alone - apoc.node.relationships.exist relTypes parameter', () => {
76
+ expect(
77
+ escapeDescriptionForMarkdown('syntax; `[<]TYPE[>]|.... with a * in it'),
78
+ ).toBe('syntax; `[<]TYPE[>]|.... with a \\* in it');
79
+ });
80
+
81
+ test('leaves a backtick that is never closed alone - apoc.path.expandConfig startNode parameter', () => {
82
+ const description =
83
+ 'The node to start the algorithm from. `startNode` can be of type `STRING` (elementId()), `INTEGER` (id()), `NODE`, or `LIST<STRING | INTEGER | NODE>.';
84
+
85
+ expect(escapeDescriptionForMarkdown(description)).toBe(description);
86
+ });
87
+
88
+ test('pairs the backticks within a line, not across the description', () => {
89
+ expect(escapeDescriptionForMarkdown('a ` b\nc * d')).toBe('a ` b\nc \\* d');
90
+ });
91
+
92
+ test('keeps the newlines a description brings, including a trailing one', () => {
93
+ expect(escapeDescriptionForMarkdown('first\n\nsecond\n')).toBe(
94
+ 'first\n\nsecond\n',
95
+ );
96
+ });
97
+
98
+ test('handles empty description', () => {
99
+ expect(escapeDescriptionForMarkdown('')).toBe('');
100
+ });
101
+ });
102
+
103
+ describe('description bullets', () => {
104
+ test('keeps the bullet markers of a description that writes a list - dbms.cluster.switchDiscoveryServiceVersion', () => {
105
+ const description = [
106
+ 'Possible values are:',
107
+ '',
108
+ '* `V1_ONLY` -- it runs only discovery service v1.',
109
+ '* `V2_ONLY` -- it runs only discovery service v2.',
110
+ ].join('\n');
111
+
112
+ expect(escapeDescriptionForMarkdown(description)).toBe(description);
113
+ });
114
+
115
+ test('escapes an asterisk that only looks like a bullet marker', () => {
116
+ // An indented bullet is not one in react-codemirror's renderer
117
+ expect(escapeDescriptionForMarkdown(' * indented')).toBe(' \\* indented');
118
+ // And neither is a bare asterisk without the space
119
+ expect(escapeDescriptionForMarkdown('*emphasised*')).toBe(
120
+ '\\*emphasised\\*',
121
+ );
122
+ // The marker is kept, the wildcard on the same line is not
123
+ expect(escapeDescriptionForMarkdown("* 'skip' -- skips *:*")).toBe(
124
+ "* 'skip' -- skips \\*:\\*",
125
+ );
126
+ });
127
+
128
+ test('writes a prose description inline', () => {
129
+ expect(
130
+ formatLineForMarkdown('query', "A query for MBeans (e.g. '*:*')."),
131
+ ).toEqual(["- `query` - A query for MBeans (e.g. '\\*:\\*')."]);
132
+ });
133
+
134
+ test('accepts a description that is several lines of prose as a textblock', () => {
135
+ expect(
136
+ formatLineForMarkdown('sort', 'Sorts the list.\nTo sort ascending.'),
137
+ ).toEqual([
138
+ '- `sort` -',
139
+ '```text',
140
+ `Sorts the list.
141
+ To sort ascending.`,
142
+ '```',
143
+ ]);
144
+ });
145
+
146
+ test('writes a description that carries its own layout as a code block - apoc.export.csv.all config parameter', () => {
147
+ const description =
148
+ '{\n delimiter = "," :: STRING,\n skipLines = 1 :: INTEGER\n}\n';
149
+
150
+ expect(formatLineForMarkdown('config', description)).toEqual([
151
+ '- `config` -',
152
+ '```text',
153
+ '{\n delimiter = "," :: STRING,\n skipLines = 1 :: INTEGER\n}',
154
+ '```',
155
+ ]);
156
+ });
157
+ });
@@ -0,0 +1,232 @@
1
+ import {
2
+ CypherLanguageService,
3
+ testData,
4
+ } from '@neo4j-cypher/language-support';
5
+ import { describe, expect, test } from 'vitest';
6
+ import { hoverInfoToMarkdown } from '../hoverMarkdown.js';
7
+
8
+ const dbSchema = testData.mockSchema;
9
+ const languageService = new CypherLanguageService();
10
+
11
+ function hoverMarkdown(query: string, hovered: string): string {
12
+ const hoverInfo = languageService.hoverInfo(query, {
13
+ caretPosition: query.indexOf(hovered) + 1,
14
+ dbSchema,
15
+ });
16
+ return hoverInfoToMarkdown(hoverInfo);
17
+ }
18
+
19
+ function variableHoverMarkdown(query: string, caretPosition: number): string {
20
+ const { symbolTables } = languageService.lint(query, dbSchema);
21
+ languageService.setSymbolsInfo({ query, symbolTables });
22
+ return hoverInfoToMarkdown(
23
+ languageService.hoverInfo(query, { caretPosition, dbSchema }),
24
+ );
25
+ }
26
+
27
+ describe('Function hover markdown', () => {
28
+ test('writes the signature, description, parameters and return type', () => {
29
+ expect(hoverMarkdown('CYPHER 25 RETURN abs(1,2)', 'abs')).toBe(
30
+ `\`\`\`cypher
31
+ abs(input :: INTEGER | FLOAT) :: INTEGER | FLOAT
32
+ \`\`\`
33
+ Returns the absolute value of an \`INTEGER\` or \`FLOAT\`.
34
+
35
+ **Parameters**
36
+ - \`input\` - A numeric value from which the absolute number will be returned.
37
+
38
+ **Returns:** \`INTEGER | FLOAT\``,
39
+ );
40
+ });
41
+
42
+ test('marks deprecated functions as deprecated', () => {
43
+ expect(
44
+ hoverMarkdown('CYPHER 5 RETURN apoc.create.uuid()', 'apoc.create.uuid'),
45
+ ).toBe(
46
+ `\`\`\`cypher
47
+ apoc.create.uuid() :: STRING
48
+ \`\`\`
49
+ (_deprecated_) Returns a UUID.
50
+
51
+
52
+ **Returns:** \`STRING\``,
53
+ );
54
+ });
55
+
56
+ test('escapes the asterisk a description writes as prose, so its not interpreted as emphasis in markdown', () => {
57
+ expect(
58
+ hoverMarkdown(
59
+ 'RETURN apoc.math.sigmoidPrime(1.0)',
60
+ 'apoc.math.sigmoidPrime',
61
+ ),
62
+ ).toBe(
63
+ `\`\`\`cypher
64
+ apoc.math.sigmoidPrime(value :: FLOAT) :: FLOAT
65
+ \`\`\`
66
+ Returns the sigmoid prime [ sigmoid(val) \\* (1 - sigmoid(val)) ] of the given value.
67
+
68
+ **Parameters**
69
+ - \`value\` - An angle in radians.
70
+
71
+ **Returns:** \`FLOAT\``,
72
+ );
73
+ });
74
+ });
75
+
76
+ describe('Procedure hover markdown', () => {
77
+ test('writes the return values of a procedure without parameters', () => {
78
+ expect(hoverMarkdown('CALL db.labels()', 'db.labels')).toBe(
79
+ `\`\`\`cypher
80
+ db.labels() :: (label :: STRING)
81
+ \`\`\`
82
+ List all labels attached to nodes within a database according to the user's access rights. The procedure returns empty results if the user is not authorized to view those labels.
83
+
84
+
85
+ **Returns**
86
+ - \`label\` - A label within the database.`,
87
+ );
88
+ });
89
+
90
+ test('writes the parameters of a procedure without return values', () => {
91
+ expect(
92
+ hoverMarkdown('CALL db.awaitIndex("MyIndex", 300)', 'db.awaitIndex'),
93
+ ).toBe(
94
+ `\`\`\`cypher
95
+ db.awaitIndex(indexName :: STRING, timeOutSeconds = 300 :: INTEGER)
96
+ \`\`\`
97
+ Wait for an index to come online (for example: CALL db.awaitIndex("MyIndex", 300)).
98
+
99
+ **Parameters**
100
+ - \`indexName\` - The name of the awaited index.
101
+ - \`timeOutSeconds\` - The maximum time to wait in seconds.
102
+ `,
103
+ );
104
+ });
105
+
106
+ test('marks deprecated procedures as deprecated', () => {
107
+ expect(
108
+ hoverMarkdown(
109
+ 'CYPHER 5 CALL db.create.setVectorProperty(n, "prop", [1.0])',
110
+ 'db.create.setVectorProperty',
111
+ ),
112
+ ).toBe(
113
+ `\`\`\`cypher
114
+ db.create.setVectorProperty(node :: NODE, key :: STRING, vector :: ANY) :: (node :: NODE)
115
+ \`\`\`
116
+ (_deprecated_) Set a vector property on a given node in a more space efficient representation than Cypher's SET.
117
+
118
+ **Parameters**
119
+ - \`node\` - The node on which the new property will be stored.
120
+ - \`key\` - The name of the new property.
121
+ - \`vector\` - The object containing the embedding.
122
+
123
+ **Returns**
124
+ - \`node\` - The node on which the vector property was set.`,
125
+ );
126
+ });
127
+
128
+ test('escapes the wildcards a description writes, but not the ones in its code spans', () => {
129
+ expect(hoverMarkdown('CALL dbms.queryJmx("*:*")', 'dbms.queryJmx')).toBe(
130
+ `\`\`\`cypher
131
+ dbms.queryJmx(query :: STRING) :: (name :: STRING, description :: STRING, attributes :: MAP)
132
+ \`\`\`
133
+ Query JMX management data by domain and name. For instance, use \`*:*\` to find all JMX beans.
134
+
135
+ **Parameters**
136
+ - \`query\` - A query for MBeans on this MBeanServer (e.g. '\\*:\\*,name=\\*neo4j\\*' for all metrics in neo4j database).
137
+
138
+ **Returns**
139
+ - \`name\` - The name of the metric.
140
+ - \`description\` - The description of the metric.
141
+ - \`attributes\` - A collection with the attributes (values) of that metric.`,
142
+ );
143
+ });
144
+
145
+ test('keeps the bullet list and the italics of a description', () => {
146
+ expect(
147
+ hoverMarkdown(
148
+ 'CALL db.index.fulltext.queryNodes("index", "query")',
149
+ 'db.index.fulltext.queryNodes',
150
+ ),
151
+ ).toBe(
152
+ `\`\`\`cypher
153
+ db.index.fulltext.queryNodes(indexName :: STRING, queryString :: STRING, options = {} :: MAP) :: (node :: NODE, score :: FLOAT)
154
+ \`\`\`
155
+ Query the given full-text index. Returns the matching nodes and their Lucene query score, ordered by score.
156
+ Valid _key: value_ pairs for the \`options\` map are:
157
+
158
+ * 'skip' -- to skip the top N results.
159
+ * 'limit' -- to limit the number of results returned.
160
+ * 'analyzer' -- to use the specified analyzer as a search analyzer for this query.
161
+
162
+ The \`options\` map and any of the keys are optional.
163
+ An example of the \`options\` map: \`{skip: 30, limit: 10, analyzer: 'whitespace'}\`
164
+
165
+
166
+ **Parameters**
167
+ - \`indexName\` - The name of the full-text index.
168
+ - \`queryString\` - The string to find approximate matches for.
169
+ - \`options\` - {skip :: INTEGER, limit :: INTEGER, analyzer :: STRING}
170
+
171
+ **Returns**
172
+ - \`node\` - A node which contains a property similar to the query string.
173
+ - \`score\` - The score measuring how similar the node property is to the query string.`,
174
+ );
175
+ });
176
+
177
+ /* Some parameters are described with a map type rather than with prose.
178
+ Markdown would collapse the layout of those into one run-on line. */
179
+ test('writes a parameter that carries its own layout as a code block', () => {
180
+ expect(
181
+ hoverMarkdown(
182
+ 'CALL apoc.export.csv.all("file.csv", {})',
183
+ 'apoc.export.csv.all',
184
+ ),
185
+ ).toContain(
186
+ `**Parameters**
187
+ - \`file\` - The name of the file to which the data will be exported.
188
+ - \`config\` -
189
+ \`\`\`text
190
+ {
191
+ stream = false :: BOOLEAN,
192
+ batchSize = 20000 :: INTEGER,
193
+ bulkImport = false :: BOOLEAN,
194
+ timeoutSeconds = 100 :: INTEGER,
195
+ compression = 'None' :: STRING,
196
+ charset = 'UTF_8' :: STRING,
197
+ quotes = 'always' :: ['always', 'none', 'ifNeeded'],
198
+ differentiateNulls = false :: BOOLEAN,
199
+ sampling = false :: BOOLEAN,
200
+ samplingConfig :: MAP
201
+ }
202
+ \`\`\``,
203
+ );
204
+ });
205
+ });
206
+
207
+ describe('Variable hover markdown', () => {
208
+ test('writes the type of a variable without labels', () => {
209
+ expect(variableHoverMarkdown('MATCH (n) RETURN n', 'MATCH ('.length)).toBe(
210
+ `\`
211
+ n: Node
212
+ \``,
213
+ );
214
+ });
215
+
216
+ test('writes the label expression of a labeled variable', () => {
217
+ expect(
218
+ variableHoverMarkdown(
219
+ 'MATCH (n:Person|Pet) WHERE n:Neighbour RETURN ',
220
+ 'MATCH (n:Person|Pet) WHERE '.length,
221
+ ),
222
+ ).toBe(
223
+ `\`
224
+ n: Node
225
+ \`
226
+
227
+ \`\`\`cypher
228
+ ((Person | Pet) & Neighbour)
229
+ \`\`\``,
230
+ );
231
+ });
232
+ });