diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index 2ac122af..2beec8be 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -14,7 +14,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v3 with: - node-version: '18.x' + node-version: '20.x' - name: Install dependencies run: npm install diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 053df1eb..8d8bfacb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [ 18, 20 ] + node: [ 20 ] name: Linting on Ubuntu with Node ${{ matrix.node }} steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index c865ca11..8010498e 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [ 18, 20 ] + node: [ 20 ] name: Prettier on Ubuntu with Node ${{ matrix.node }} steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 5e4c8264..cf16c78b 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -18,7 +18,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v3 with: - node-version: '18.x' + node-version: '20.x' - name: Install dependencies run: npm install diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f0bea023..5172f8b0 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [ 18, 20 ] + node: [ 20, 22 ] name: Tests with code coverage on Ubuntu with Node ${{ matrix.node }} steps: - uses: actions/checkout@v3 @@ -25,7 +25,7 @@ jobs: runs-on: windows-latest strategy: matrix: - node: [ 18, 20 ] + node: [ 20, 22 ] name: Tests with code coverage on Windows with Node ${{ matrix.node }} steps: - uses: actions/checkout@v3 diff --git a/lsp/server/src/server.ts b/lsp/server/src/server.ts index 31846718..da397b6c 100644 --- a/lsp/server/src/server.ts +++ b/lsp/server/src/server.ts @@ -19,10 +19,10 @@ import { CodeActionKind } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { validateDocument } from './validateDocument'; import { OrgUtils } from './utils/orgUtils'; import { WorkspaceUtils } from './utils/workspaceUtils'; import { getSettings } from './diagnostic/DiagnosticSettings'; +import { ValidatorManager } from './validatorManager'; import { debounce } from './utils/commonUtils'; // Create a connection for the server, using Node's IPC as a transport. @@ -41,6 +41,9 @@ let diagnosticsSettingSection = ''; // initialize default settings let settings = getSettings({}); + +const validatorManager = ValidatorManager.createInstance(); + const documentCache: Map = new Map(); connection.onInitialize((params: InitializeParams) => { @@ -172,7 +175,11 @@ connection.languages.diagnostics.on(async (params) => { if (document !== undefined) { return { kind: DocumentDiagnosticReportKind.Full, - items: await validateDocument(settings, document, extensionTitle) + items: await validatorManager.validateDocument( + settings, + document, + extensionTitle + ) } satisfies DocumentDiagnosticReport; } else { // We don't know the document. We can either try to read it from disk diff --git a/lsp/server/src/test/validateGraphql.test.ts b/lsp/server/src/test/validator/graphqlValidator.test.ts similarity index 66% rename from lsp/server/src/test/validateGraphql.test.ts rename to lsp/server/src/test/validator/graphqlValidator.test.ts index 5f740c94..397731dc 100644 --- a/lsp/server/src/test/validateGraphql.test.ts +++ b/lsp/server/src/test/validator/graphqlValidator.test.ts @@ -6,15 +6,17 @@ */ import { TextDocument } from 'vscode-languageserver-textdocument'; -import { validateGraphql } from '../validateGraphql'; +import { GraphQLValidator } from '../../validator/gqlValidator'; +import { OversizedRecord } from '../../diagnostic/gql/over-sized-record'; + import * as assert from 'assert'; import { suite, test, beforeEach, afterEach } from 'mocha'; import * as sinon from 'sinon'; -import { OrgUtils } from '../utils/orgUtils'; -import Book__c from '../../testFixture/objectInfos/Book__c.json'; -import { ObjectInfoRepresentation } from '../types'; +import { OrgUtils } from '../../utils/orgUtils'; +import Book__c from '../../../testFixture/objectInfos/Book__c.json'; +import { ObjectInfoRepresentation } from '../../types'; -suite('Diagnostics Test Suite - Server - Validate GraphQL', () => { +suite('Diagnostics Test Suite - Server - GraphQL Validator', () => { let sandbox: sinon.SinonSandbox; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -54,7 +56,17 @@ suite('Diagnostics Test Suite - Server - Validate GraphQL', () => { }; ` ); - const diagnostics = await validateGraphql({}, textDocument); + + const graphqlValidator = new GraphQLValidator(); + graphqlValidator.addProducer(new OversizedRecord()); + const sections = + graphqlValidator.gatherDiagnosticSections(textDocument); + assert.equal(sections.length, 1); + const diagnostics = await graphqlValidator.validateData( + {}, + sections[0].document, + sections[0].data + ); assert.equal(diagnostics.length, 2); }); @@ -74,8 +86,10 @@ suite('Diagnostics Test Suite - Server - Validate GraphQL', () => { }; ` ); - const diagnostics = await validateGraphql({}, textDocument); - - assert.equal(diagnostics.length, 0); + const graphqlValidator = new GraphQLValidator(); + graphqlValidator.addProducer(new OversizedRecord()); + const sections = + graphqlValidator.gatherDiagnosticSections(textDocument); + assert.equal(sections.length, 0); }); }); diff --git a/lsp/server/src/test/validateHtml.test.ts b/lsp/server/src/test/validator/htmlValidator.test.ts similarity index 66% rename from lsp/server/src/test/validateHtml.test.ts rename to lsp/server/src/test/validator/htmlValidator.test.ts index 082c52ef..a95aa023 100644 --- a/lsp/server/src/test/validateHtml.test.ts +++ b/lsp/server/src/test/validator/htmlValidator.test.ts @@ -6,11 +6,15 @@ */ import { TextDocument } from 'vscode-languageserver-textdocument'; -import { validateHtml } from '../validateHtml'; import { suite, test } from 'mocha'; +import { HTMLValidator } from '../../validator/htmlValidator'; import * as assert from 'assert'; +import { MobileOfflineFriendly } from '../../diagnostic/html/mobileOfflineFriendly'; + +suite('Diagnostics Test Suite - Server - HTML Validator', () => { + const htmlValidator: HTMLValidator = new HTMLValidator(); + htmlValidator.addProducer(new MobileOfflineFriendly()); -suite('Diagnostics Test Suite - Server - Validate html', () => { test('Correct number of non-friendly mobile offline base components is determined', async () => { const textDocument = TextDocument.create( 'file://test.html', @@ -30,7 +34,15 @@ suite('Diagnostics Test Suite - Server - Validate html', () => { ` ); - const diagnostics = await validateHtml({}, textDocument); + const htmlSections = + htmlValidator.gatherDiagnosticSections(textDocument); + assert.equal(htmlSections.length, 1); + + const diagnostics = await htmlValidator.validateData( + {}, + htmlSections[0].document, + htmlSections[0].data + ); assert.equal(diagnostics.length, 2); }); @@ -57,7 +69,15 @@ suite('Diagnostics Test Suite - Server - Validate html', () => { ` ); - const diagnostics = await validateHtml({}, textDocument); + const htmlSections = + htmlValidator.gatherDiagnosticSections(textDocument); + assert.equal(htmlSections.length, 1); + + const diagnostics = await htmlValidator.validateData( + {}, + htmlSections[0].document, + htmlSections[0].data + ); assert.equal(diagnostics.length, 3); }); }); diff --git a/lsp/server/src/test/validateJs.test.ts b/lsp/server/src/test/validator/jsValidator.test.ts similarity index 62% rename from lsp/server/src/test/validateJs.test.ts rename to lsp/server/src/test/validator/jsValidator.test.ts index a4edd490..3a873e2b 100644 --- a/lsp/server/src/test/validateJs.test.ts +++ b/lsp/server/src/test/validator/jsValidator.test.ts @@ -8,13 +8,18 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import * as assert from 'assert'; import { suite, test } from 'mocha'; -import { validateJs } from '../validateJs'; + +import { JSValidator } from '../../validator/jsValidator'; import { + AdaptersLocalChangeNotAware, LOCAL_CHANGE_NOT_AWARE_MESSAGE, RULE_ID -} from '../diagnostic/js/adapters-local-change-not-aware'; +} from '../../diagnostic/js/adapters-local-change-not-aware'; + +suite('Diagnostics Test Suite - Server - JS Validator', () => { + const jsValidator = new JSValidator(); + jsValidator.addProducer(new AdaptersLocalChangeNotAware()); -suite('Diagnostics Test Suite - Server - Validate JS', () => { const textDocument = TextDocument.create( 'file://test.js', 'javascript', @@ -41,24 +46,38 @@ suite('Diagnostics Test Suite - Server - Validate JS', () => { ); test('Validate local change not aware adapters', async () => { - const diagnostics = await validateJs({}, textDocument); + const jsSections = jsValidator.gatherDiagnosticSections(textDocument); + assert.equal(jsSections.length, 1); + const diagnostics = await jsValidator.validateData( + {}, + jsSections[0].document, + jsSections[0].data + ); assert.equal(diagnostics.length, 1); assert.equal(diagnostics[0].message, LOCAL_CHANGE_NOT_AWARE_MESSAGE); }); test('No diagnostics return if individually suppressed', async () => { - const diagnostics = await validateJs( + const jsSections = jsValidator.gatherDiagnosticSections(textDocument); + + const diagnostics = await jsValidator.validateData( { suppressByRuleId: new Set([RULE_ID]) }, - textDocument + jsSections[0].document, + jsSections[0].data ); + assert.equal(diagnostics.length, 0); }); test('No diagnostics return if all suppressed', async () => { - const diagnostics = await validateJs( + const jsSections = jsValidator.gatherDiagnosticSections(textDocument); + + const diagnostics = await jsValidator.validateData( { suppressAll: true }, - textDocument + jsSections[0].document, + jsSections[0].data ); + assert.equal(diagnostics.length, 0); }); @@ -71,8 +90,8 @@ suite('Diagnostics Test Suite - Server - Validate JS', () => { var var i = 100; ` ); - const diagnostics = await validateJs({}, textDocument); + const jsSections = jsValidator.gatherDiagnosticSections(textDocument); - assert.equal(diagnostics.length, 0); + assert.equal(jsSections.length, 0); }); }); diff --git a/lsp/server/src/test/validatorManager.test.ts b/lsp/server/src/test/validatorManager.test.ts new file mode 100644 index 00000000..c90cb5e7 --- /dev/null +++ b/lsp/server/src/test/validatorManager.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ + +import { TextDocument } from 'vscode-languageserver-textdocument'; +import * as assert from 'assert'; +import { suite, test } from 'mocha'; + +import { ValidatorManager } from '../validatorManager'; +import { LOCAL_CHANGE_NOT_AWARE_MESSAGE } from '../diagnostic/js/adapters-local-change-not-aware'; + +suite('Diagnostics Test Suite - Server - ValidatorManager', () => { + const validatorManager = ValidatorManager.createInstance(); + const textDocument = TextDocument.create( + 'file://test.js', + 'javascript', + 1, + ` + import { LightningElement, wire } from "lwc"; + import { getRelatedListRecords } from "lightning/uiRelatedListApi"; + + export default class RelatedListRecords extends LightningElement { + + recordId = "0015g00000XYZABC"; + + relatedRecords; + + @wire(getRelatedListRecords, { + parentRecordId: "$recordId", + relatedListId: "Opportunities", + fields: ["Opportunity.Name"], + }) + relatedListHandler({ error, data }) { + } + } + ` + ); + + test('Validate JS file with local change not aware adapter', async () => { + const diagnostics = await validatorManager.validateDocument( + {}, + textDocument, + 'testExtension' + ); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].message, LOCAL_CHANGE_NOT_AWARE_MESSAGE); + }); +}); diff --git a/lsp/server/src/utils/babelUtil.ts b/lsp/server/src/utils/babelUtil.ts index e6c0759c..2855956e 100644 --- a/lsp/server/src/utils/babelUtil.ts +++ b/lsp/server/src/utils/babelUtil.ts @@ -6,7 +6,7 @@ */ import * as parser from '@babel/parser'; -import { Node } from '@babel/types'; +import type { Node } from '@babel/types'; /** * parse the input javascript source code and return the corresponding babel node. diff --git a/lsp/server/src/validateDocument.ts b/lsp/server/src/validateDocument.ts deleted file mode 100644 index 0d1c7679..00000000 --- a/lsp/server/src/validateDocument.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { Diagnostic } from 'vscode-languageserver/node'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import { validateJs } from './validateJs'; -import { validateGraphql } from './validateGraphql'; -import { validateHtml } from './validateHtml'; -import { DiagnosticSettings } from './diagnostic/DiagnosticSettings'; - -/** - * Validate the document based on its extension type. - * For HTML, apply HTML rules. - * For JavaScript, parse with Babel and apply JavaScript rules. - * For GraphQL tagged templates, parse the GraphQL string and apply GraphQL rules. - * - * @param document Text document to validate. - * @returns Diagnostic results for the document. - */ -export async function validateDocument( - setting: DiagnosticSettings, - document: TextDocument, - extensionName: string -): Promise { - const { uri } = document; - - let results: Diagnostic[] = []; - - if (document.languageId === 'javascript') { - // handles JS rules - const jsDiagnostics = await validateJs(setting, document); - - // handle graphql rules - const graphqlDiagnostics = await validateGraphql(setting, document); - - results = results.concat(jsDiagnostics, graphqlDiagnostics); - } - - if (document.languageId === 'html') { - const diagnostics = await validateHtml(setting, document); - results = results.concat(diagnostics); - } - - // Set the source for diagnostic source. - results.forEach((diagnostic) => { - diagnostic.source = extensionName; - }); - - return results; -} diff --git a/lsp/server/src/validateGraphql.ts b/lsp/server/src/validateGraphql.ts deleted file mode 100644 index 7420c536..00000000 --- a/lsp/server/src/validateGraphql.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { parse, ASTNode } from 'graphql'; -import { gqlPluckFromCodeStringSync } from '@graphql-tools/graphql-tag-pluck'; -import { Diagnostic } from 'vscode-languageserver/node'; -import { DiagnosticProducer } from './diagnostic/DiagnosticProducer'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import { - DiagnosticSettings, - isTheDiagnosticSuppressed -} from './diagnostic/DiagnosticSettings'; -import { OversizedRecord } from './diagnostic/gql/over-sized-record'; - -const diagnosticProducers: DiagnosticProducer[] = [ - new OversizedRecord() -]; - -/** - * Validate the graphql queries in the document. - * @param setting The diagnostic settings. - * @param textDocument The document to validate. - */ -export async function validateGraphql( - setting: DiagnosticSettings, - textDocument: TextDocument -): Promise { - const results: Diagnostic[] = []; - - const producers = diagnosticProducers.filter((producer) => { - return !isTheDiagnosticSuppressed(setting, producer.getId()); - }); - - if (producers.length === 0) { - return results; - } - - // Find the gql``s in the file content - const graphQueries = gqlPluckFromCodeStringSync( - textDocument.uri, - textDocument.getText(), - { - skipIndent: true, - globalGqlIdentifierName: ['gql', 'graphql'] - } - ); - - // Validate each query - for (const query of graphQueries) { - const lineOffset = query.locationOffset.line - 1; - const columnOffset = query.locationOffset.column + 1; - const graphqlTextDocument = TextDocument.create( - ``, - 'graphql', - 1, - query.body - ); - const diagnostics = await validateOneGraphQuery( - producers, - graphqlTextDocument, - query.body - ); - // Update the range offset correctly - for (const item of diagnostics) { - updateDiagnosticOffset(item, lineOffset, columnOffset); - results.push(item); - } - } - - return results; -} - -/** - * Validate graphql diagnostic rules to a graph query, return empty list if the graphql string is invalid. - * @param producers The diagnostic producer to run. - * @param graphql the graph code - * @param graphqlDiagnosticProducers the collection of graphql rules. - */ -export async function validateOneGraphQuery( - producers: DiagnosticProducer[], - textDocument: TextDocument, - graphql: string -): Promise { - try { - const graphqlAstNode = parse(graphql); - const allResults = await Promise.all( - producers.map((producer) => { - return producer - .validateDocument(textDocument, graphqlAstNode) - .then((diagnostics) => { - const producerId = producer.getId(); - diagnostics.forEach((diagnostic) => { - diagnostic.data = producerId; - }); - return diagnostics; - }); - }) - ); - return allResults.flat(); - } catch (e) { - // Graphql string fails to parse will not produce diagnostic - } - - return []; -} - -/** - * Update the graphql diagnostic offset to offset from the whole js file - * @param diagnostic - * @param lineOffset Line offset from the file - * @param columnOffset Column offset from the file - */ -function updateDiagnosticOffset( - diagnostic: Diagnostic, - lineOffset: number, - columnOffset: number -) { - const start = diagnostic.range.start; - const end = diagnostic.range.end; - - // Only add the column offset for first line. - if (start.line === 0) { - start.character += columnOffset; - } - if (end.line === 0) { - end.character += columnOffset; - } - - start.line += lineOffset; - end.line += lineOffset; -} diff --git a/lsp/server/src/validateHtml.ts b/lsp/server/src/validateHtml.ts deleted file mode 100644 index 4df6cfd3..00000000 --- a/lsp/server/src/validateHtml.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { HTMLDocument, getLanguageService } from 'vscode-html-languageservice'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import { Diagnostic } from 'vscode-languageserver/node'; -import { DiagnosticProducer } from './diagnostic/DiagnosticProducer'; -import { - DiagnosticSettings, - isTheDiagnosticSuppressed -} from './diagnostic/DiagnosticSettings'; -import { MobileOfflineFriendly } from './diagnostic/html/mobileOfflineFriendly'; - -const diagnosticProducers: DiagnosticProducer[] = [ - new MobileOfflineFriendly() -]; - -function parseHTMLContent(content: TextDocument): HTMLDocument { - const htmlLanguageService = getLanguageService(); - return htmlLanguageService.parseHTMLDocument(content); -} - -export async function validateHtml( - setting: DiagnosticSettings, - textDocument: TextDocument -): Promise { - let results: Diagnostic[] = []; - - const producers = diagnosticProducers.filter((producer) => { - return !isTheDiagnosticSuppressed(setting, producer.getId()); - }); - - if (producers.length > 0) { - try { - const htmlDocument = parseHTMLContent(textDocument); - - for (const producer of diagnosticProducers) { - const producerId = producer.getId(); - const diagnostics = await producer.validateDocument( - textDocument, - htmlDocument - ); - diagnostics.forEach((diagnostic) => { - diagnostic.data = producerId; - }); - results = results.concat(diagnostics); - } - } catch (e) {} - } - return results; -} diff --git a/lsp/server/src/validateJs.ts b/lsp/server/src/validateJs.ts deleted file mode 100644 index cdf1fc57..00000000 --- a/lsp/server/src/validateJs.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: MIT - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT - */ - -import { Diagnostic } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-textdocument'; -import { parseJs } from './utils/babelUtil'; -import { Node } from '@babel/types'; -import { DiagnosticProducer } from './diagnostic/DiagnosticProducer'; -import { AdaptersLocalChangeNotAware } from './diagnostic/js/adapters-local-change-not-aware'; -import { - isTheDiagnosticSuppressed, - DiagnosticSettings -} from './diagnostic/DiagnosticSettings'; - -const jsDiagnosticProducers: DiagnosticProducer[] = [ - new AdaptersLocalChangeNotAware() -]; - -/** - * Validate JavaScript file content. - * @param fileContent The JavaScript file content - * @returns An array of diagnostics found within the JavaScript file - */ -export async function validateJs( - setting: DiagnosticSettings, - textDocument: TextDocument -): Promise { - let results: Diagnostic[] = []; - - const producers = jsDiagnosticProducers.filter((producer) => { - return !isTheDiagnosticSuppressed(setting, producer.getId()); - }); - - if (producers.length > 0) { - try { - const jsNode = parseJs(textDocument.getText()); - for (const producer of jsDiagnosticProducers) { - const producerId = producer.getId(); - const diagnostics = await producer.validateDocument( - textDocument, - jsNode - ); - diagnostics.forEach((diagnostic) => { - diagnostic.data = producerId; - }); - results = results.concat(diagnostics); - } - } catch (e) {} // Silence error since JS parsing error crashes app. - } - return results; -} diff --git a/lsp/server/src/validator/baseValidator.ts b/lsp/server/src/validator/baseValidator.ts new file mode 100644 index 00000000..f9d2bd76 --- /dev/null +++ b/lsp/server/src/validator/baseValidator.ts @@ -0,0 +1,109 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { DiagnosticProducer } from '../diagnostic/DiagnosticProducer'; +import { + DiagnosticSettings, + isTheDiagnosticSuppressed +} from '../diagnostic/DiagnosticSettings'; +import { Diagnostic } from 'vscode-languageserver'; +import type { Node } from '@babel/types'; +import type { ASTNode } from 'graphql'; +import type { HTMLDocument } from 'vscode-html-languageservice'; + +export type SupportedType = Node | ASTNode | HTMLDocument; + +/** + * The DiagnosticSection interface represents a distinct segment of a document that a DiagnosticProducer can process independently. + * Each data field corresponds to a specific part of the document, with lineOffset and columnOffset indicating the section’s relative position + * within the entire document. This allows diagnostic producers to process specific portions effectively. + */ +export interface DiagnosticSection { + data: SupportedType; + document: TextDocument; + lineOffset: number; + columnOffset: number; +} +/** + * The `BaseValidator` class is an abstract foundation for managing `DiagnosticProducer` instances that generate diagnostics. + * It enables adding/removing producers, gathering diagnostic sections (via `gatherDiagnosticSections`), validating data, and + * specifying a language ID. This structure supports extensible, language-specific validation across data types. + */ +export abstract class BaseValidator { + private producers: Array>; + + constructor() { + this.producers = []; + } + + /** + * Add a diagnostic producer to the list if it isn't already present. + * @param producer The diagnostic producer to be added. + */ + public addProducer(producer: DiagnosticProducer) { + if ( + !this.producers.some((existingProducer) => { + producer.getId === existingProducer.getId; + }) + ) { + this.producers.push(producer); + } + } + + /** + * Remove a diagnostic producer from the list. + * @param producerId The Id of diagnostic producer to be removed. + */ + public removeProducer(producerId: string) { + this.producers = this.producers.filter( + (producer) => producer.getId() !== producerId + ); + } + + /** + * Prepare diagnostic sections for each producer to process. + * @param textDocument The document to analyze. + * @returns An array of diagnostic sections relevant to the producers. + */ + abstract gatherDiagnosticSections( + textDocument: TextDocument + ): Array>; + + /** + * Validate data against active diagnostic producers and generates diagnostics. + * @param setting The diagnostic settings. + * @param textDocument The document to analyze. + * @param data The data to validate. + * @returns An array of diagnostics generated by active producers. + */ + async validateData( + setting: DiagnosticSettings, + textDocument: TextDocument, + data: SupportedType + ): Promise> { + const activeProducers = this.producers.filter((producer) => { + return !isTheDiagnosticSuppressed(setting, producer.getId()); + }); + + if (activeProducers.length === 0) { + return []; + } + + const diagnosticsArray = await Promise.all( + activeProducers.map(async (producer) => { + try { + return await producer.validateDocument(textDocument, data); + } catch (e) { + console.log( + `Cannot diagnose document with rule ID ${producer.getId()}: ${(e as Error).message}` + ); + } + return []; + }) + ); + return diagnosticsArray.flat(); + } + + /** + * Language Id this validator handles + */ + abstract getLanguageId(): string; +} diff --git a/lsp/server/src/validator/gqlValidator.ts b/lsp/server/src/validator/gqlValidator.ts new file mode 100644 index 00000000..d97777c8 --- /dev/null +++ b/lsp/server/src/validator/gqlValidator.ts @@ -0,0 +1,57 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { BaseValidator } from './baseValidator'; +import { parse, ASTNode } from 'graphql'; +import { gqlPluckFromCodeStringSync } from '@graphql-tools/graphql-tag-pluck'; + +import { DiagnosticSection } from './baseValidator'; +export class GraphQLValidator extends BaseValidator { + getLanguageId(): string { + return 'javascript'; + } + + /** + * Each GQL text document can have more than 1 `DiagnosticSection` + * @param textDocument The gql document to analyze. + * @returns An array of diagnostic sections relevant to the producers. + */ + gatherDiagnosticSections( + textDocument: TextDocument + ): DiagnosticSection[] { + const gqlSources = gqlPluckFromCodeStringSync( + textDocument.uri, + textDocument.getText(), + { + skipIndent: true, + globalGqlIdentifierName: ['gql', 'graphql'] + } + ); + + const results: DiagnosticSection[] = []; + for (const source of gqlSources) { + try { + const { line, column } = source.locationOffset; + const gqlTextDocument = TextDocument.create( + ``, + 'graphql', + 1, + source.body + ); + + const astNode = parse(source.body); + + const section = { + data: astNode, + document: gqlTextDocument, + lineOffset: line - 1, + columnOffset: column + 1 + } satisfies DiagnosticSection; + results.push(section); + } catch (e) { + console.log( + `Unable to parse GQL document: ${(e as Error).message}` + ); + } + } + return results; + } +} diff --git a/lsp/server/src/validator/htmlValidator.ts b/lsp/server/src/validator/htmlValidator.ts new file mode 100644 index 00000000..8897b2fd --- /dev/null +++ b/lsp/server/src/validator/htmlValidator.ts @@ -0,0 +1,29 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { BaseValidator } from './baseValidator'; + +import { DiagnosticSection } from './baseValidator'; +import { HTMLDocument, getLanguageService } from 'vscode-html-languageservice'; +export class HTMLValidator extends BaseValidator { + gatherDiagnosticSections( + textDocument: TextDocument + ): DiagnosticSection[] { + try { + const data = getLanguageService().parseHTMLDocument(textDocument); + + return [ + { + data, + document: textDocument, + lineOffset: 0, + columnOffset: 0 + } satisfies DiagnosticSection + ]; + } catch (e) { + console.log(`Failed to parse HTML file: : ${(e as Error).message}`); + } + return []; + } + getLanguageId(): string { + return 'html'; + } +} diff --git a/lsp/server/src/validator/jsValidator.ts b/lsp/server/src/validator/jsValidator.ts new file mode 100644 index 00000000..52b2e0f7 --- /dev/null +++ b/lsp/server/src/validator/jsValidator.ts @@ -0,0 +1,33 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { BaseValidator } from './baseValidator'; +import type { Node } from '@babel/types'; +import { parseJs } from '../utils/babelUtil'; +import { DiagnosticSection } from './baseValidator'; + +export class JSValidator extends BaseValidator { + gatherDiagnosticSections( + textDocument: TextDocument + ): DiagnosticSection[] { + try { + const data = parseJs(textDocument.getText()); + //One DiagnosticSection with lineOffset and columnOffset as 0 + return [ + { + data, + document: textDocument, + lineOffset: 0, + columnOffset: 0 + } satisfies DiagnosticSection + ]; + } catch (e) { + console.log( + `Failed to parse JavaScript file: : ${(e as Error).message}` + ); + } + return []; + } + + getLanguageId(): string { + return 'javascript'; + } +} diff --git a/lsp/server/src/validatorManager.ts b/lsp/server/src/validatorManager.ts new file mode 100644 index 00000000..89f78669 --- /dev/null +++ b/lsp/server/src/validatorManager.ts @@ -0,0 +1,162 @@ +import { Diagnostic } from 'vscode-languageserver'; + +import { JSValidator } from './validator/jsValidator'; +import { HTMLValidator } from './validator/htmlValidator'; +import { GraphQLValidator } from './validator/gqlValidator'; +import { DiagnosticSection } from './validator/baseValidator'; + +import { TextDocument } from 'vscode-languageserver-textdocument'; + +import { DiagnosticSettings } from './diagnostic/DiagnosticSettings'; +import { OversizedRecord } from './diagnostic/gql/over-sized-record'; +import { AdaptersLocalChangeNotAware } from './diagnostic/js/adapters-local-change-not-aware'; +import { MobileOfflineFriendly } from './diagnostic/html/mobileOfflineFriendly'; + +import type { BaseValidator, SupportedType } from './validator/baseValidator'; + +/** + * The ValidatorManager class manages a collection of BaseValidator instances and coordinates the validation process for documents. + * It filters relevant validators based on the document's language, applies each to designated sections of the document, and aggregates diagnostics + * for consistent, language-specific validation. This class centralizes validation logic, enabling efficient, extensible diagnostic processing + * across multiple validators. + */ +export class ValidatorManager { + // Store all available validators + private validators: BaseValidator[] = []; + + private constructor() {} + + /** + * Adds a validator to the manager’s collection. + * @param validator The validator to be added. + */ + public addValidator(validator: BaseValidator) { + this.validators.push(validator); + } + + /** + * Validate a document by applying all relevant validators based on language. + * @param setting The diagnostic settings. + * @param document The document to validate. + * @param extensionName The name of the extension (sets diagnostic source). + * @returns A promise resolving to an array of diagnostics. + */ + async validateDocument( + setting: DiagnosticSettings, + document: TextDocument, + extensionName: string + ): Promise { + const qualifiers = this.validators.filter( + (validator) => validator.getLanguageId() === document.languageId + ); + const diagnosticArray = await Promise.all( + qualifiers.map(async (validator) => { + try { + return await this.applyValidator( + setting, + document, + validator + ); + } catch (e) {} + return []; + }) + ); + const results = diagnosticArray.flat(); + results.forEach((diagnostic) => (diagnostic.source = extensionName)); + + return results; + } + + /** + * Apply a specific validator to designated sections within the document. + * @param setting The diagnostic settings. + * @param document The document to validate. + * @param validator The validator to apply. + * @returns A promise resolving to an array of diagnostics from the validator. + */ + private async applyValidator( + setting: DiagnosticSettings, + document: TextDocument, + validator: BaseValidator + ): Promise { + // Gather sections of the document relevant to diagnostics + const sections: DiagnosticSection[] = + validator.gatherDiagnosticSections(document); + + // Validate each section and apply line and column offsets to diagnostics + const sectionDiagnostics = await Promise.all( + sections.map(async (section) => { + const { data, document, lineOffset, columnOffset } = section; + try { + const diagnostics = await validator.validateData( + setting, + document, + data + ); + // Adjust diagnostics with section-specific offsets + for (const diagnostic of diagnostics) { + this.updateDiagnosticOffset( + diagnostic, + lineOffset, + columnOffset + ); + } + return diagnostics; + } catch (e) {} + return []; + }) + ); + + return sectionDiagnostics.flat(); + } + + /** + * Update the line and column positions of a diagnostic based on section offsets. + * @param diagnostic The diagnostic to adjust. + * @param lineOffset The line offset to apply. + * @param columnOffset The column offset to apply. + */ + private updateDiagnosticOffset( + diagnostic: Diagnostic, + lineOffset: number, + columnOffset: number + ) { + const start = diagnostic.range.start; + const end = diagnostic.range.end; + + // Only add the column offset for first line. + if (start.line === 0) { + start.character += columnOffset; + } + if (end.line === 0) { + end.character += columnOffset; + } + + start.line += lineOffset; + end.line += lineOffset; + } + + /** + * The createInstance method is a static factory method that creates and returns an instance of ValidatorManager with pre-configured validators. + * It initializes ValidatorManager, then adds instances of GraphQLValidator, JSValidator, and HTMLValidator to it. Each validator is configured + * with relevant diagnostic producers. + * @returns ValidatorManager instance + */ + public static createInstance(): ValidatorManager { + const validatorManager = new ValidatorManager(); + // Populate GraphQLValidator + const gqlValidator = new GraphQLValidator(); + gqlValidator.addProducer(new OversizedRecord()); + validatorManager.addValidator(gqlValidator); + + const jsValidator = new JSValidator(); + jsValidator.addProducer(new AdaptersLocalChangeNotAware()); + validatorManager.addValidator(jsValidator); + + const htmlValidator = new HTMLValidator(); + htmlValidator.addProducer(new MobileOfflineFriendly()); + validatorManager.addValidator(htmlValidator); + + return validatorManager; + } +} diff --git a/package-lock.json b/package-lock.json index 6e25d72d..f3b91aa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8894,10 +8894,10 @@ "tslib": "^2.0.3" } }, - "node_modules/node-abi": { - "version": "3.71.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", - "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "node_modules/node-abi": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz", + "integrity": "sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==", "dev": true, "license": "MIT", "optional": true,