feat(editor): add native Neo4j Cypher completion

This commit is contained in:
Hand Sonic
2026-10-19 03:20:51 +08:00
committed by GitHub
parent 063801e1af
commit 7b40a6ffca
3 changed files with 139 additions and 0 deletions
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { buildGraphCompletionItems, getGraphCompletionContext } from "../graphCompletion";
import { getGraphFunctionSignatureHelp } from "../graphSignatureHelp";
import profile from "./neo4jQueryProfile";
describe("Neo4j Cypher completion profile", () => {
it("covers the common Neo4j 5 function families without nGQL-only functions", () => {
const names = profile.functions.map((fn) => fn.name);
for (const name of ["rand", "randomUUID", "percentileCont", "stDev", "isEmpty", "point", "point.distance", "duration.between", "toStringOrNull", "elementId"]) expect(names).toContain(name);
for (const name of ["src", "dst", "rank", "now", "uuid"]) expect(names).not.toContain(name);
for (const word of ["DROP", "ALTER", "INDEXES", "FUNCTIONS", "PROCEDURES", "TRANSACTIONS"]) expect(profile.keywords).toContain(word);
});
it("completes qualified functions with leaf-only replacement and signature help", () => {
const source = "RETURN point.di";
const items = buildGraphCompletionItems(getGraphCompletionContext(source, source.length, "neo4j"), "neo4j");
expect(items).toEqual([expect.objectContaining({ label: "distance", apply: "distance(${from}, ${to})" })]);
const call = "RETURN duration.between(a, ";
expect(getGraphFunctionSignatureHelp(call, call.length, "neo4j")).toMatchObject({ name: "duration.between", activeParameter: 1 });
});
it("prefers bound variables over function namespaces", () => {
const source = "MATCH (point:Person) RETURN point.na";
expect(getGraphCompletionContext(source, source.length, "neo4j")).toMatchObject({ mode: "property", objects: ["Person"] });
});
it("does not misrepresent node labels as relationship types", () => {
const source = "MATCH ()-[r:";
expect(buildGraphCompletionItems(getGraphCompletionContext(source, source.length, "neo4j"), "neo4j", { tables: [{ name: "Person", type: "table" }] })).toEqual([]);
});
});
@@ -0,0 +1,108 @@
import { commonGraphFunctions, functionDefinitions, graphListPredicates, graphReduce, temporalFunctions, type GraphQueryProfile } from "../graphQueryProfile";
// Neo4j 5 built-ins: https://neo4j.com/docs/cypher-manual/5/functions/
const reservedWords =
"ALL ALTER AND AS ASC ASCENDING ASSERT BY CALL CASE COMMIT COMPOSITE CONSTRAINT CONSTRAINTS CONTAINS CREATE CSV CURRENT DATABASE DATABASES DELETE DESC DESCENDING DETACH DISTINCT DROP ELSE END ENDS EXCEPT EXECUTABLE EXISTS EXPLAIN FALSE FIELDTERMINATOR FOR FOREACH FROM FUNCTIONS HEADERS IF IN INDEX INDEXES IS JOIN LIMIT LOAD MATCH MERGE NODE NODETACH NOT NULL OF ON OPTIONAL OR ORDER PERIODIC PRIVILEGES PROCEDURE PROCEDURES PROFILE REMOVE RENAME REQUIRE RETURN ROLE ROLES SCAN SERVER SERVERS SET SETTINGS SHOW SKIP START STARTS THEN TO TRANSACTION TRANSACTIONS TRUE UNION UNIQUE UNWIND USE USER USERS USING WHEN WHERE WITH XOR YIELD".split(
" ",
);
// Cypher 5.19+ path selectors/restrictors (SHORTEST/WALK/TRAIL/ACYCLIC/SIMPLE) and index
// DDL words (LOOKUP/FULLTEXT) complete and highlight as keywords but stay out of
// reservedWords; POINT/RANGE/TEXT/GROUP are intentionally omitted because they are common
// variable/property names and keyword-ness breaks bound-variable resolution.
const keywords = [...new Set([...reservedWords, ..."ACYCLIC FULLTEXT LOOKUP SHORTEST SIMPLE TRAIL WALK".split(" ")])];
const profile: GraphQueryProfile = {
databaseType: "neo4j",
language: "Cypher",
lineComment: "//",
keywords,
reservedWords,
functions: [
...commonGraphFunctions,
...functionDefinitions({
length: ["path"],
timestamp: [],
elementId: ["entity"],
endNode: ["relationship"],
startNode: ["relationship"],
toLower: ["string"],
toUpper: ["string"],
lower: ["string"],
upper: ["string"],
exists: ["property"],
isEmpty: ["value"],
shortestPath: ["pattern"],
allShortestPaths: ["pattern"],
percentileCont: ["expression", "percentile"],
percentileDisc: ["expression", "percentile"],
stDev: ["expression"],
stDevP: ["expression"],
rand: [],
randomUUID: [],
e: [],
pi: [],
exp: ["number"],
log: ["number"],
log10: ["number"],
isNaN: ["number"],
acos: ["number"],
asin: ["number"],
atan: ["number"],
atan2: ["y", "x"],
cos: ["number"],
cot: ["number"],
degrees: ["radians"],
haversin: ["number"],
radians: ["degrees"],
sin: ["number"],
tan: ["number"],
point: ["map"],
"point.distance": ["from", "to"],
"point.withinBBox": ["point", "lowerLeft", "upperRight"],
"duration.between": ["from", "to"],
"duration.inDays": ["from", "to"],
"duration.inMonths": ["from", "to"],
"duration.inSeconds": ["from", "to"],
"datetime.fromEpoch": ["seconds", "nanoseconds"],
"datetime.fromEpochMillis": ["milliseconds"],
"db.nameFromElementId": ["elementId"],
"graph.byElementId": ["elementId"],
"graph.byName": ["name"],
"graph.names": [],
"graph.propertiesByName": ["name"],
"vector.similarity.cosine": ["a", "b"],
"vector.similarity.euclidean": ["a", "b"],
nullIf: ["value1", "value2"],
valueType: ["value"],
char_length: ["string"],
character_length: ["string"],
btrim: ["string", "characters?"],
ltrim: ["string", "characters?"],
rtrim: ["string", "characters?"],
normalize: ["string", "normalForm?"],
toBooleanOrNull: ["value"],
toFloatOrNull: ["value"],
toIntegerOrNull: ["value"],
toStringOrNull: ["value"],
toBooleanList: ["list"],
toFloatList: ["list"],
toIntegerList: ["list"],
toStringList: ["list"],
file: [],
linenumber: [],
}),
...temporalFunctions(["date", "datetime", "localdatetime", "localtime", "time"]),
...["date", "datetime", "localdatetime", "localtime", "time"].flatMap((type) =>
functionDefinitions({
[`${type}.realtime`]: ["timezone?"],
[`${type}.statement`]: ["timezone?"],
[`${type}.transaction`]: ["timezone?"],
[`${type}.truncate`]: ["unit", "input?", "fields?"],
}),
),
...graphListPredicates,
graphReduce,
],
};
export default profile;
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB