Skip to content

Enables linter switching feature #542

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jul 23, 2025
Merged
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
5 changes: 1 addition & 4 deletions packages/language-server/src/linting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ async function rawLintDocument(
document: TextDocument,
sendDiagnostics: (diagnostics: Diagnostic[]) => void,
neo4j: Neo4jSchemaPoller,
versionedLinters: boolean,
) {
const query = document.getText();
if (query.length === 0) {
Expand All @@ -62,9 +61,7 @@ async function rawLintDocument(

const proxyWorker = (await pool.proxy()) as unknown as LintWorker;

const fixedDbSchema = versionedLinters
? convertDbSchema(dbSchema, linterVersion)
: dbSchema;
const fixedDbSchema = convertDbSchema(dbSchema, linterVersion);
lastSemanticJob = proxyWorker.lintCypherQuery(
query,
fixedDbSchema,
Expand Down
1 change: 0 additions & 1 deletion packages/language-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ async function lintSingleDocument(document: TextDocument): Promise<void> {
});
},
neo4jSchemaPoller,
settings?.features?.useVersionedLinters,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR cleans up this flag

);
} else {
void connection.sendDiagnostics({
Expand Down
2 changes: 1 addition & 1 deletion packages/language-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export type Neo4jSettings = {
trace: {
server: 'off' | 'messages' | 'verbose';
};
features: { linting: boolean; useVersionedLinters?: boolean };
features: { linting: boolean };
};

export type Neo4jParameters = Record<string, unknown>;
3 changes: 1 addition & 2 deletions packages/lint-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"cypher",
"lint worker"
],
"version": "0.1.0-next.0",
"version": "1.10.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From now onwards you'd need to bump this by hand I suppose

"repository": {
"type": "git",
"url": "git://github.com/neo4j/cypher-language-support.git"
Expand All @@ -34,7 +34,6 @@
"@neo4j-cypher/language-support": "workspace:*",
"@neo4j-cypher/query-tools": "workspace:*",
"languageSupport-next.13": "npm:@neo4j-cypher/[email protected]",
"languageSupport-next.8": "npm:@neo4j-cypher/[email protected]",
"vscode-languageserver": "^8.1.0",
"vscode-languageserver-types": "^3.17.3",
"workerpool": "^9.0.4",
Expand Down
54 changes: 35 additions & 19 deletions packages/lint-worker/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,26 @@ import {
import axios from 'axios';
import { DbSchema as DbSchemaV1 } from 'languageSupport-next.13';

const oldLinter = '5.20.0';

// for older versions of the language support, the dbschema was not the same,
// meaning old linters need conversion of the new schema
export function convertDbSchema(
originalSchema: DbSchemaV2,
linterVersion: string,
): DbSchemaV2 | DbSchemaV1 {
let oldFunctions: Record<string, Neo4jFunction> = {};
let oldProcedures: Record<string, Neo4jProcedure> = {};
if (!originalSchema) {
return originalSchema;
}
if (
originalSchema.functions['CYPHER 5'] &&
originalSchema.procedures['CYPHER 5']
Comment on lines -24 to -25
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was unsafe, the functions and procedures fields could be undefined if they haven't been polled from the database yet, causing this to blow up

) {
oldFunctions = originalSchema.functions['CYPHER 5'];
oldProcedures = originalSchema.procedures['CYPHER 5'];
}

if (compareMajorMinorVersions(linterVersion, oldLinter) <= 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was not correct. The check needs to be any version lower than 2025.01, which is when we changed the schema.

if (compareMajorMinorVersions(linterVersion, '2025.01') < 0) {
let oldFunctions: Record<string, Neo4jFunction> | undefined = undefined;
let oldProcedures: Record<string, Neo4jProcedure> | undefined = undefined;
if (originalSchema.functions && originalSchema.functions['CYPHER 5']) {
oldFunctions = originalSchema.functions['CYPHER 5'];
}

if (originalSchema.procedures && originalSchema.procedures['CYPHER 5']) {
oldProcedures = originalSchema.procedures['CYPHER 5'];
}
const dbSchemaOld: DbSchemaV1 = {
...originalSchema,
functions: oldFunctions,
Expand All @@ -40,12 +38,30 @@ export function convertDbSchema(
}
}

export function serverVersionToLinter(serverVersion: string) {
let candidate: string = 'Latest';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the term Latest to Default because if someone is on an older version of the plugin, that means they will just have the default linter shipped with the plugin (which wouldn't be the latest one is that plugin version is older)

if (compareMajorMinorVersions(serverVersion, oldLinter) <= 0) {
candidate = oldLinter;
export function serverVersionToLinter(serverVersion: string): {
linterVersion: string;
notResolved?: boolean;
notSupported?: boolean;
} {
// Extract only the major and minor
const versionRegex = /^\d+\.\d+/;
const linterVersion = serverVersion.match(versionRegex)?.[0];

// If we have a version lower than 5.23, use that linter
if (compareMajorMinorVersions(serverVersion, '5.23') < 0) {
return { linterVersion: '5.23', notSupported: true };
// Unfortunately 2025.01, 2025.02 and 2025.03 all return 5.27
// so we have to assume we are on the most modern database from all those
//
// This case should never happen though because we should have cleaned up the
// version by the moment we call this method
} else if (compareMajorMinorVersions(serverVersion, '5.27') === 0) {
return { linterVersion: '2025.03' };
} else if (linterVersion) {
return { linterVersion: linterVersion };
}
return candidate;

return { linterVersion: 'Default', notResolved: true };
}

export function linterFileToServerVersion(fileName: string) {
Expand All @@ -64,7 +80,7 @@ export type NpmRelease = {
};

export const npmTagToLinterVersion = (tag: string) =>
tag.match(/^neo4j-([\d.]+)$/)?.[1];
tag.match(/^neo4j-(\d+\.\d+)$/)?.[1];

export async function getTaggedRegistryVersions(): Promise<NpmRelease[]> {
const registryUrl = 'https://registry.npmjs.org/@neo4j-cypher/lint-worker';
Expand All @@ -74,7 +90,7 @@ export async function getTaggedRegistryVersions(): Promise<NpmRelease[]> {
const taggedVersions: { tag: string; version: string }[] = [];
if (data !== null && data['dist-tags'] !== null) {
for (const [tag, version] of Object.entries(data['dist-tags'])) {
if (typeof tag === 'string' && typeof version === 'string') {
if (npmTagToLinterVersion(tag)) {
taggedVersions.push({ tag, version });
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/lint-worker/src/lintWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function lintCypherQuery(
query: string,
dbSchema,
featureFlags: { consoleCommands?: boolean } = {},
): { diagnostics: SyntaxDiagnostic[]; symbolTables: SymbolTable[] } {
): { diagnostics: SyntaxDiagnostic[]; symbolTables?: SymbolTable[] } {
// We allow to override the consoleCommands feature flag
if (featureFlags.consoleCommands !== undefined) {
_internalFeatureFlags.consoleCommands = featureFlags.consoleCommands;
Expand Down
14 changes: 8 additions & 6 deletions packages/lint-worker/src/tests/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import { compareMajorMinorVersions } from '../version';

describe('Version comparison', () => {
test('Comparing versions for major/minor version', () => {
expect(compareMajorMinorVersions('5.24.0', '2025.01')).toBe(-1);
expect(compareMajorMinorVersions('5.22.0', '5.23')).toBe(-1);
expect(compareMajorMinorVersions('5.24', '2025.01')).toBe(-1);
expect(compareMajorMinorVersions('2025.01', '5.24.0')).toBe(1);
expect(compareMajorMinorVersions('2025.01', '5.24')).toBe(1);
expect(compareMajorMinorVersions('2025.01', '2025.02')).toBe(-1);
expect(compareMajorMinorVersions('2025.01', '2025.01')).toBe(0);

expect(compareMajorMinorVersions('5.24.0', '5.25.0')).toBe(-1);
expect(compareMajorMinorVersions('5.24.0', '5.25.1')).toBe(-1);
expect(compareMajorMinorVersions('5.24.5', '5.25.3')).toBe(-1);
Expand Down Expand Up @@ -29,11 +37,5 @@ describe('Version comparison', () => {
expect(compareMajorMinorVersions('4.4.1', '3.5.2000')).toBe(1);
expect(compareMajorMinorVersions('2024.5.0', '2023.500.30')).toBe(1);
expect(compareMajorMinorVersions('4.5.0-alpha', '3.5.0-beta')).toBe(1);

expect(compareMajorMinorVersions('2025.06.3-alpha', '3.y.3-beta')).toBe(
undefined,
);
expect(compareMajorMinorVersions('5.02.y', '3.01.y')).toBe(undefined);
expect(compareMajorMinorVersions('bla.bla.bla', '5.25.0')).toBe(undefined);
});
});
5 changes: 4 additions & 1 deletion packages/lint-worker/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ export function compareMajorMinorVersions(
): integer | undefined {
const semVer1: semver.SemVer | null = semver.coerce(version1, {
includePrerelease: false,
loose: true,
});
const semVer2: semver.SemVer | null = semver.coerce(version2, {
includePrerelease: false,
loose: true,
});
if (semVer1 && semVer2) {
semVer1.patch = 0;
semVer2.patch = 0;
return semver.compare(semVer1, semVer2);
const result = semver.compare(semVer1, semVer2, true);
return result;
}
return undefined;
}
14 changes: 13 additions & 1 deletion packages/query-tools/src/schemaPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,24 @@ export class Neo4jSchemaPoller {

const { query: serverVersionQuery, queryConfig: serverVersionQueryConfig } =
getVersion();
const { serverVersion } = await this.driver.executeQuery(
let { serverVersion } = await this.driver.executeQuery(
serverVersionQuery,
{},
serverVersionQueryConfig,
);

// If the server version is 5.27.0 we have to use the agent to
// really know whether we are in 2025.01, 2025.02 or 2025.03
if (serverVersion?.startsWith('5.27.0')) {
const agent = summary.server.agent;
if (agent) {
const matchedVersion = agent.match(/\d+\.\d+\.\d+/);
if (matchedVersion) {
serverVersion = matchedVersion[0];
}
}
}

this.connection.serverVersion = serverVersion;

this.metadata = new ConnectedMetadataPoller(
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-extension/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# neo4j-for-vscode

## 1.13.0
- Adjusts linting automatically depending on the neo4j version.
- Updates grammar and semantic analysis

## 1.12.0
Expand Down
16 changes: 13 additions & 3 deletions packages/vscode-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,21 @@ Once you've written your desired query (for example `CREATE (n)-[r:Rel]->(m) RET

![demo-execution](https://github.com/neo4j/cypher-language-support/blob/main/packages/vscode-extension/resources/images/demo-execution.png?raw=true)

## Upcoming features
## Version tailored linting

We're working on adding more features to the extension, such as:
Our aim is to provide an experience that suits the different neo4j versions you could be connected to. When connecting to a database, a linter that matches that version of the database will be automatically downloaded.

- Dynamically adjusting the language server depending on the neo4j version.
![demo-linter-automatic-adjusting](https://github.com/neo4j/cypher-language-support/blob/main/packages/vscode-extension/resources/images/demo-linter-automatic-adjusting.png?raw=true).

![demo-linter-5](https://github.com/neo4j/cypher-language-support/blob/main/packages/vscode-extension/resources/images/demo-linter-5.png?raw=true).

![demo-linter-2025](https://github.com/neo4j/cypher-language-support/blob/main/packages/vscode-extension/resources/images/demo-linter-2025.png?raw=true).

We can match a neo4j version from 5.23 onwards. If connected to an older database, the 5.23 version will be used. If the version cannot be resolved from your neo4j instance for any reason, the `Default` linter (the one packaged with the current version of the VSCode extension) will be used.

The linter can be manually adjusted either on the bottom menu or using the `Neo4j: Select Cypher linter version` command from the Command Palette.

![demo-linter-manual-adjusting](https://github.com/neo4j/cypher-language-support/blob/main/packages/vscode-extension/resources/images/demo-linter-manual-adjusting.png?raw=true).

## Extension settings

Expand Down
21 changes: 5 additions & 16 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
{
"command": "neo4j.switchLinter",
"category": "Neo4j",
"title": "Pick linter"
"title": "Select Cypher linter version"
},
{
"command": "neo4j.editConnection",
Expand Down Expand Up @@ -160,8 +160,7 @@
],
"commandPalette": [
{
"command": "neo4j.switchLinter",
"when": "neo4j:useVersionedLinters"
"command": "neo4j.switchLinter"
},
{
"command": "neo4j.deleteConnection",
Expand Down Expand Up @@ -445,16 +444,6 @@
],
"default": true,
"description": "Enable linting errors for Cypher queries"
},
"neo4j.features.useVersionedLinters": {
"scope": "window",
"type": "boolean",
"enum": [
true,
false
],
"default": false,
"description": "Enable neo4j version specifc linting"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've cleaned up this feature flag

}
}
},
Expand All @@ -478,9 +467,9 @@
"dev": "tsc -b && pnpm gen-textmate && concurrently 'node --experimental-strip-types esbuild-extension.mts' 'pnpm bundle-webview-controllers --watch' ",
"clean": "rm -rf {dist,tsconfig.tsbuildinfo}",
"package": "pnpm vsce package --pre-release --no-dependencies",
"test:e2e": "DEBUG_VSCODE_TESTS=false && pnpm build:dev && pnpm test:apiAndUnit && pnpm test:webviews",
"test:apiAndUnit": "DEBUG_VSCODE_TESTS=false && pnpm build:dev && rm -rf .vscode-test/user-data && node ./dist/tests/runApiAndUnitTests.js",
"test:webviews": "DEBUG_VSCODE_TESTS=false && pnpm build:dev && wdio run ./dist/tests/runWebviewTests.js"
"test:e2e": "pnpm build:dev && cross-env DEBUG_VSCODE_TESTS=false pnpm test:apiAndUnit && cross-env DEBUG_VSCODE_TESTS=false pnpm test:webviews",
"test:apiAndUnit": "pnpm build:dev && rm -rf .vscode-test/user-data && cross-env DEBUG_VSCODE_TESTS=false node ./dist/tests/runApiAndUnitTests.js",
"test:webviews": "pnpm build:dev && cross-env DEBUG_VSCODE_TESTS=false wdio run ./dist/tests/runWebviewTests.js"
Comment on lines +470 to +472
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a bug here (which did not have any implications until now) and the DEBUG_VSCODE_TESTS variable was not passed to the test job because you either do:

export VARIABLE=value && job

or you have to prepend setting the variable just before every job.

},
"dependencies": {
"@neo4j-cypher/language-server": "workspace:*",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 8 additions & 3 deletions packages/vscode-extension/src/commandHandlers/linters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
compareMajorMinorVersions,
getTaggedRegistryVersions,
linterFileToServerVersion,
npmTagToLinterVersion,
Expand All @@ -21,12 +22,16 @@ export async function manuallyAdjustLinter(): Promise<void> {
const existingVersions = fileNames.map((name) =>
linterFileToServerVersion(name),
);
existingVersions.push('Latest');
const allVersions = new Set(
existingVersions.concat(npmLinterVersions).filter((v) => v !== undefined),
);
const picked = await window.showQuickPick(Array.from(allVersions), {
placeHolder: 'Select Linter version',
// This is to show Default on top and then the versions in decreasing order
const sanitizedVersions = [
'Default',
...Array.from(allVersions).sort(compareMajorMinorVersions).reverse(),
];
const picked = await window.showQuickPick(sanitizedVersions, {
placeHolder: 'Select Cypher linter version',
});
//closing the quickpick menu will return undefined
if (picked === undefined) {
Expand Down
24 changes: 18 additions & 6 deletions packages/vscode-extension/src/connectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import * as schemaPollerEventHandlers from './schemaPollerEventHandlers';
import { connectionTreeDataProvider } from './treeviews/connectionTreeDataProvider';
import { databaseInformationTreeDataProvider } from './treeviews/databaseInformationTreeDataProvider';
import { displayMessageForConnectionResult } from './uiUtils';
import * as vscode from 'vscode';
import { dynamicallyAdjustLinter } from './linterSwitching';
import { dynamicallyAdjustLinter, switchToLinter } from './linterSwitching';

export type Scheme =
| 'neo4j'
Expand Down Expand Up @@ -422,10 +421,23 @@ async function connectToDatabaseAndNotifyLanguageClient(
? await sendNotificationToLanguageClient('connectionUpdated', settings)
: await sendNotificationToLanguageClient('connectionDisconnected');

const config = vscode.workspace.getConfiguration('neo4j.features');
const versionedLintersEnabled = config.get('useVersionedLinters', false);
if (result.success && versionedLintersEnabled) {
await dynamicallyAdjustLinter();
// Note the e2e tests are always going to be on an older neo4j version (a docker container)
// We want for all of the tests to run with the latest version of the linter,
// not an older one (which would not make sense to debug them for example)
//
// except for the tests that are specifically about switching the linter
if (result.success) {
if (process.env.DEBUG_VSCODE_TESTS !== undefined) {
// tests code
if (process.env.LINTER_SWITCHING_TESTS === 'true') {
await dynamicallyAdjustLinter();
} else {
await switchToLinter('Default', []);
}
} else {
// production code
await dynamicallyAdjustLinter();
}
}

await saveConnection({
Expand Down
6 changes: 6 additions & 0 deletions packages/vscode-extension/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const CONSTANTS = {
GLOBALSTORAGE_READ_FAILED: 'Failed to read neo4j globalStorage directory.',
LINTER_DOWNLOAD_FAILED:
'Linter download failed, reverting to best match from currently downloaded linter versions.',
LINTER_VERSION_NOT_AVAILABLE:
'Required linter not available for download, default linting experience will be used.',
LINTER_SERVER_NOT_RESOLVED:
'Neo4j version could not be resolved, default linting experience will be used.',
LINTER_SERVER_NOT_SUPPORTED:
'Neo4j version is lower than 5.23, your will have a degraded linting experience.',
CONNECTED_MESSAGE: 'Connected to Neo4j.',
DISCONNECTED_MESSAGE: 'Disconnected from Neo4j.',
RECONNECTED_MESSAGE: 'Reconnected to Neo4j.',
Expand Down
Loading