From 46f319102e4a96ea4d2ca941e46f4be1470c539a Mon Sep 17 00:00:00 2001 From: Roshan Ghojoghi Date: Wed, 8 Nov 2023 15:33:07 +0300 Subject: [PATCH] feat: ajv validation errors markers (#182) * feat: ajv validation errors markers * enhance: ajv error messages update * fix: typo --- .../monaco-editor/ValidationErrorConsole.tsx | 4 +- .../otelCollectorConfigValidation.test.ts | 56 ++++++++++-- .../otelCollectorConfigValidation.ts | 42 +++++++-- .../monaco-editor/parseYaml.test.ts | 85 ++++++++++++++++++- .../src/components/monaco-editor/parseYaml.ts | 24 +++++- .../src/components/react-flow/FlowClick.ts | 4 +- .../otelbin/src/contexts/EditorContext.tsx | 6 +- 7 files changed, 198 insertions(+), 23 deletions(-) diff --git a/packages/otelbin/src/components/monaco-editor/ValidationErrorConsole.tsx b/packages/otelbin/src/components/monaco-editor/ValidationErrorConsole.tsx index ca852f9c..90e428ff 100644 --- a/packages/otelbin/src/components/monaco-editor/ValidationErrorConsole.tsx +++ b/packages/otelbin/src/components/monaco-editor/ValidationErrorConsole.tsx @@ -8,6 +8,7 @@ import { useServerSideValidation } from "../validation/useServerSideValidation"; export interface IAjvError { message: string; + line?: number | null; } export interface IJsYamlError { @@ -26,7 +27,6 @@ export interface IError { export default function ValidationErrorConsole({ errors, font }: { errors?: IError; font: NextFont }) { const serverSideValidationResult = useServerSideValidation(); - const errorCount = (errors?.ajvErrors?.length ?? 0) + (errors?.jsYamlError != null ? 1 : 0) + @@ -123,7 +123,7 @@ export function ErrorMessage({ )} {ajvError && (
-

{`${ajvError.message}`}

+

{`${ajvError.message} ${(ajvError.line ?? 0) > 1 ? `(Line ${ajvError.line})` : ""}`}

)} {customErrors && ( diff --git a/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.test.ts b/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.test.ts index 271ff800..83d2cb02 100644 --- a/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.test.ts +++ b/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.test.ts @@ -3,15 +3,17 @@ import { describe, expect, test, it } from "@jest/globals"; import { + type IValidateItem, + type IItem, + type IYamlElement, findLineAndColumn, extractMainItemsData, extractServiceItems, findLeafs, - getParsedValue, - type IValidateItem, - type IItem, + getYamlDocument, + parseYaml, } from "./parseYaml"; -import { capitalize, customValidate } from "./otelCollectorConfigValidation"; +import { capitalize, customValidate, findErrorElement } from "./otelCollectorConfigValidation"; import type { editor } from "monaco-editor"; const editorBinding = { @@ -71,7 +73,7 @@ test("find Line And Column of the given offset in a string", () => { describe("extractMainItemsData", () => { it("should correctly extract level 1 and leve2 key value pairs with level2 offset", () => { const yaml = editorBinding.fallback; - const docElements = getParsedValue(yaml); + const docElements = getYamlDocument(yaml); const result = extractMainItemsData(docElements); const expectedOutput: IValidateItem = { @@ -95,7 +97,7 @@ describe("extractMainItemsData", () => { describe("findLeafs", () => { it("should return leaf level and the parent of the leaf with offsets for the given yaml item", () => { const yaml = editorBinding.fallback; - const docElements = getParsedValue(yaml); + const docElements = getYamlDocument(yaml); const yamlItems = extractServiceItems(docElements); const result = findLeafs(yamlItems, docElements.filter((item: IItem) => item.key?.source === "service")[0], {}); @@ -185,3 +187,45 @@ describe("customValidate", () => { }); }); }); + +// Tested with brief editorBinding.fallback +describe("findErrorElement", () => { + const yaml = editorBinding.fallback; + const docElements = getYamlDocument(yaml); + const parsedYaml = parseYaml(docElements); + const exampleAjvErrorPath = ["service", "pipelines", "traces", "exporters"]; + + it("should correctly find last element of ajv validation errorPath from a yaml file that parsed with parseYaml function", () => { + const result = findErrorElement(exampleAjvErrorPath, parsedYaml); + + const expectedOutput: IYamlElement = { + key: "exporters", + offset: 174, + value: [ + { + key: "otlp", + offset: 186, + value: "otlp", + }, + ], + }; + + expect(result).toEqual(expectedOutput); + }); + + it("with empty error path should return undefined", () => { + const result = findErrorElement([], parsedYaml); + + const expectedOutput = undefined; + + expect(result).toEqual(expectedOutput); + }); + + it("with empty parsed yaml doc should return undefined", () => { + const result = findErrorElement(exampleAjvErrorPath, []); + + const expectedOutput = undefined; + + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.ts b/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.ts index 3dbaf32b..f2c2f951 100644 --- a/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.ts +++ b/packages/otelbin/src/components/monaco-editor/otelCollectorConfigValidation.ts @@ -11,12 +11,14 @@ import type { editor } from "monaco-editor"; import { type Monaco } from "@monaco-editor/react"; import { type IItem, - getParsedValue, + type IYamlElement, type IValidateItem, + getYamlDocument, extractMainItemsData, extractServiceItems, findLeafs, findLineAndColumn, + parseYaml, } from "./parseYaml"; type EditorRefType = RefObject; @@ -35,7 +37,8 @@ export function validateOtelCollectorConfigurationAndSetMarkers( const ajvError: IAjvError[] = []; const totalErrors: IError = { ajvErrors: ajvError, customErrors: [], customWarnings: [] }; const errorMarkers: editor.IMarkerData[] = []; - const docElements = getParsedValue(configData); + const docElements = getYamlDocument(configData); + const parsedYamlConfig = parseYaml(docElements); const mainItemsData: IValidateItem = extractMainItemsData(docElements); const serviceItems: IItem[] | undefined = extractServiceItems(docElements); serviceItemsData = {}; @@ -53,12 +56,22 @@ export function validateOtelCollectorConfigurationAndSetMarkers( if (errors) { const validationErrors = errors.map((error: ErrorObject) => { + const errorPath = error.instancePath.split("/").slice(1); + const errorElement = findErrorElement(errorPath, parsedYamlConfig); + const { line, column } = findLineAndColumn(configData, errorElement?.offset); const errorInfo = { - line: null as number | null, - column: null as number | null, + line: line as number | null, + column: column as number | null, message: error.message || "Unknown error", }; - + errorMarkers.push({ + startLineNumber: errorInfo.line ?? 0, + endLineNumber: 0, + startColumn: errorInfo.column ?? 0, + endColumn: errorInfo.column ?? 0, + severity: 8, + message: errorInfo.message, + }); if (error instanceof JsYaml.YAMLException) { errorInfo.line = error.mark.line + 1; errorInfo.column = error.mark.column + 1; @@ -160,3 +173,22 @@ export function capitalize(input: string): string { return capitalized; } + +export const findErrorElement = (path: string[], data?: IYamlElement[]): IYamlElement | undefined => { + if (!path.length || !data) { + return undefined; + } + + const [head, ...tail] = path; + + for (const item of data) { + if (item.key === head) { + if (tail.length === 0 || !Array.isArray(item.value)) { + return item; + } + + return findErrorElement(tail, item.value); + } + } + return undefined; +}; diff --git a/packages/otelbin/src/components/monaco-editor/parseYaml.test.ts b/packages/otelbin/src/components/monaco-editor/parseYaml.test.ts index be31eb48..7f5a6a83 100644 --- a/packages/otelbin/src/components/monaco-editor/parseYaml.test.ts +++ b/packages/otelbin/src/components/monaco-editor/parseYaml.test.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "@jest/globals"; -import type { IItem } from "./parseYaml"; -import { getParsedValue, extractServiceItems, findPipelinesKeyValues } from "./parseYaml"; +import type { IItem, IYamlElement } from "./parseYaml"; +import { getYamlDocument, extractServiceItems, findPipelinesKeyValues, parseYaml } from "./parseYaml"; //The example contains pipelines with duplicated names (otlp and batch) const editorBinding = { @@ -48,11 +48,88 @@ testItem2: .replaceAll(/\t/g, " ") as string, } as const; +// Tested with brief serviceTest.fallback +describe("parseYaml", () => { + it("should return a minimal version of npm yaml Document consists all of the key values of yaml string with related offsets", () => { + const yaml = serviceTest.fallback; + const docElements = getYamlDocument(yaml); + const result: IYamlElement[] | undefined = parseYaml(docElements); + + expect(result).toEqual([ + { + key: "receivers", + offset: 0, + value: [ + { + key: "otlp", + offset: 13, + value: undefined, + }, + ], + }, + { + key: "processors", + offset: 19, + value: [ + { + key: "batch", + offset: 33, + value: undefined, + }, + ], + }, + { + key: "service", + offset: 40, + value: [ + { + key: "extensions", + offset: 51, + value: [ + { + key: "health_check", + offset: 64, + value: "health_check", + }, + { + key: "pprof", + offset: 78, + value: "pprof", + }, + { + key: "zpages", + offset: 85, + value: "zpages", + }, + ], + }, + ], + }, + { + key: "testItem1", + offset: 93, + value: undefined, + }, + { + key: "testItem2", + offset: 104, + value: undefined, + }, + ]); + }); + + it("should return an empty array if docElements is empty", () => { + const result = parseYaml([]); + + expect(result).toEqual([]); + }); +}); + // Tested with brief serviceTest.fallback describe("extractServiceItems", () => { it("should return service item in the doc object of the yaml parser", () => { const yaml = serviceTest.fallback; - const docElements = getParsedValue(yaml); + const docElements = getYamlDocument(yaml); const result: IItem[] | undefined = extractServiceItems(docElements); expect(result).toEqual([ @@ -105,7 +182,7 @@ describe("extractServiceItems", () => { describe("findPipelinesKeyValues", () => { it("should return return main key values (also with duplicated names) under service.pipelines with their offset in the config", () => { const yaml = editorBinding.fallback; - const docElements = getParsedValue(yaml); + const docElements = getYamlDocument(yaml); const serviceItems: IItem[] | undefined = extractServiceItems(docElements); const pipeLineItems: IItem[] | undefined = serviceItems?.filter((item: IItem) => item.key?.source === "pipelines"); diff --git a/packages/otelbin/src/components/monaco-editor/parseYaml.ts b/packages/otelbin/src/components/monaco-editor/parseYaml.ts index b7033aaa..13da8e6e 100644 --- a/packages/otelbin/src/components/monaco-editor/parseYaml.ts +++ b/packages/otelbin/src/components/monaco-editor/parseYaml.ts @@ -60,6 +60,12 @@ export interface Document { end?: SourceToken[]; } +export interface IYamlElement { + key: string; + offset: number; + value?: IYamlElement | IYamlElement[] | string; +} + export interface ILeaf { source?: string; offset: number; @@ -70,7 +76,7 @@ export interface IValidateItem { [key: string]: ILeaf[]; } -export const getParsedValue = (editorValue: string) => { +export const getYamlDocument = (editorValue: string) => { const value = editorValue; const parsedYaml = Array.from(new Parser().parse(value)); const doc = parsedYaml.find((token) => token.type === "document") as Document; @@ -78,6 +84,22 @@ export const getParsedValue = (editorValue: string) => { return docElements; }; +export function parseYaml(yamlItems: IItem[]) { + const parsedYamlConfig: IYamlElement[] = []; + if (!yamlItems) return; + else if (Array.isArray(yamlItems)) { + for (const item of yamlItems) { + if (item) { + const key = item.key?.source ?? item.value?.source; + const keyOffset = item.key?.offset ?? item.value?.offset; + const value = parseYaml(item.value?.items) ?? item.value?.source; + parsedYamlConfig.push({ key: key, offset: keyOffset, value: value }); + } + } + } + return parsedYamlConfig; +} + export function extractMainItemsData(docElements: IItem[]) { const mainItemsData: IValidateItem = {}; diff --git a/packages/otelbin/src/components/react-flow/FlowClick.ts b/packages/otelbin/src/components/react-flow/FlowClick.ts index 5a970e5c..412bc356 100644 --- a/packages/otelbin/src/components/react-flow/FlowClick.ts +++ b/packages/otelbin/src/components/react-flow/FlowClick.ts @@ -12,7 +12,7 @@ import { extractServiceItems, findLineAndColumn, findPipelinesKeyValues, - getParsedValue, + getYamlDocument, } from "../monaco-editor/parseYaml"; type EditorRefType = RefObject; @@ -30,7 +30,7 @@ export function FlowClick( ) { event.stopPropagation(); const config = editorRef?.current?.getModel()?.getValue() || ""; - const docElements = getParsedValue(config); + const docElements = getYamlDocument(config); const mainItemsData: IValidateItem = extractMainItemsData(docElements); let pipelinesKeyValues: IValidateItem | undefined = {}; const serviceItems: IItem[] | undefined = extractServiceItems(docElements); diff --git a/packages/otelbin/src/contexts/EditorContext.tsx b/packages/otelbin/src/contexts/EditorContext.tsx index a6cdee19..dc586f0e 100644 --- a/packages/otelbin/src/contexts/EditorContext.tsx +++ b/packages/otelbin/src/contexts/EditorContext.tsx @@ -10,7 +10,7 @@ import schema from "../components/monaco-editor/schema.json"; import { fromPosition, toCompletionList } from "monaco-languageserver-types"; import { type languages } from "monaco-editor/esm/vs/editor/editor.api.js"; import type { IItem } from "../components/monaco-editor/parseYaml"; -import { getParsedValue } from "../components/monaco-editor/parseYaml"; +import { getYamlDocument } from "../components/monaco-editor/parseYaml"; import { type WorkerGetter, createWorkerManager } from "monaco-worker-manager"; import { type CompletionList, type Position } from "vscode-languageserver-types"; import { validateOtelCollectorConfigurationAndSetMarkers } from "~/components/monaco-editor/otelCollectorConfigValidation"; @@ -172,10 +172,10 @@ export const EditorProvider = ({ children }: { children: ReactNode }) => { ); let value = editorRef.current?.getValue() ?? ""; - let docElements = getParsedValue(value); + let docElements = getYamlDocument(value); editorRef.current?.onDidChangeModelContent(() => { value = editorRef.current?.getValue() ?? ""; - docElements = getParsedValue(value); + docElements = getYamlDocument(value); }); function correctKey(value: string, key?: string, key2?: string) {