diff --git a/language/complexity-check-plugin/src/__tests__/complexity-check-plugin.test.ts b/language/complexity-check-plugin/src/__tests__/complexity-check-plugin.test.ts index c7cec7eb..56b689c6 100644 --- a/language/complexity-check-plugin/src/__tests__/complexity-check-plugin.test.ts +++ b/language/complexity-check-plugin/src/__tests__/complexity-check-plugin.test.ts @@ -94,6 +94,7 @@ describe("complexity plugin", () => { expect(validations?.map((v) => v.message)[0]).toMatchInlineSnapshot(` "Content complexity is 18" `); + expect(validations?.[0]?.source).toBe("complexity-check"); }); test("Measures complexity for evaluating nested bindings", async () => { @@ -454,6 +455,7 @@ describe("complexity plugin", () => { expect(validations?.map((v) => v.severity)[0]).toBe( DiagnosticSeverity.Warning, ); + expect(validations?.[0]?.source).toBe("complexity-check"); }); test("Calculates custom score criteria correctly", async () => { diff --git a/language/json-language-service/src/__tests__/__snapshots__/service.test.ts.snap b/language/json-language-service/src/__tests__/__snapshots__/service.test.ts.snap index 3a334d39..65ee5e9e 100644 --- a/language/json-language-service/src/__tests__/__snapshots__/service.test.ts.snap +++ b/language/json-language-service/src/__tests__/__snapshots__/service.test.ts.snap @@ -124,6 +124,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 1, + "source": "xlr-plugin", }, { "message": "Content Validation Error - missing: Property "BEGIN" missing from type "Navigation"", @@ -138,6 +139,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 1, + "source": "xlr-plugin", }, { "message": "Navigation Validation Error - missing: Property "BEGIN" missing from type "Navigation"", @@ -152,6 +154,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 1, + "source": "xlr-plugin", }, { "message": "View is not reachable", @@ -166,6 +169,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 2, + "source": "view-node", }, { "message": "View Validation Error - value: Does not match any of the expected types for type: 'AssetWrapperOrSwitch'", @@ -180,6 +184,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 1, + "source": "xlr-plugin", }, { "message": "Expected: AssetWrapper & object literal | StaticSwitch & object literal | DynamicSwitch & object literal", @@ -194,6 +199,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 3, + "source": "xlr-plugin", }, { "message": "Implicit Array -> "collection" assets is not supported.", @@ -208,6 +214,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] = }, }, "severity": 1, + "source": "asset-wrapper-to-array", }, ] `; diff --git a/language/json-language-service/src/__tests__/service.test.ts b/language/json-language-service/src/__tests__/service.test.ts index 1700061b..505af08a 100644 --- a/language/json-language-service/src/__tests__/service.test.ts +++ b/language/json-language-service/src/__tests__/service.test.ts @@ -5,6 +5,7 @@ import { Types, } from "@player-tools/static-xlrs"; import { PlayerLanguageService } from ".."; +import { JSON_PARSE_ERROR_SOURCE } from "../parser/jsonParseErrors"; import { toTextDocument } from "../utils"; describe("player language service", () => { @@ -107,6 +108,55 @@ describe("player language service", () => { expect(validationErrors).toMatchSnapshot(); }); + + test("adds plugin source to validation diagnostics", async () => { + const document = toTextDocument( + JSON.stringify({ + id: "foo", + views: [ + { + id: "view-1", + type: "unknown-view-type", + }, + ], + navigation: { + BEGIN: "FLOW_1", + FLOW_1: { + startState: "VIEW_1", + VIEW_1: { + state_type: "VIEW", + ref: "view-1", + transitions: { + "*": "END_Done", + }, + }, + END_Done: { + state_type: "END", + outcome: "done", + }, + }, + }, + }), + ); + const validationErrors = await service.validateTextDocument(document); + + expect(validationErrors?.length).toBeGreaterThan(0); + expect(validationErrors?.every((diag) => !!diag.source)).toBe(true); + expect( + validationErrors?.some((diag) => diag.source === "xlr-plugin"), + ).toBe(true); + }); + + test("adds parser source to parse diagnostics", async () => { + const document = toTextDocument(`{"id":"foo",}`); + const validationErrors = await service.validateTextDocument(document); + + const parseDiagnostic = validationErrors?.find( + (diag) => diag.source === JSON_PARSE_ERROR_SOURCE, + ); + + expect(parseDiagnostic).toBeDefined(); + }); }); describe("completion", () => { diff --git a/language/json-language-service/src/index.ts b/language/json-language-service/src/index.ts index f6f74938..71beec52 100644 --- a/language/json-language-service/src/index.ts +++ b/language/json-language-service/src/index.ts @@ -51,6 +51,47 @@ export * from "./types"; export * from "./parser"; export * from "./xlr/index"; +const FALLBACK_DIAGNOSTIC_SOURCE = "player-language-service"; + +const getTapSource = (tapName: unknown): string => { + if (typeof tapName === "string" && tapName.length > 0) { + return tapName; + } + + if ( + typeof tapName === "object" && + tapName !== null && + "name" in tapName && + typeof (tapName as { name?: unknown }).name === "string" && + (tapName as { name: string }).name.length > 0 + ) { + return (tapName as { name: string }).name; + } + + return FALLBACK_DIAGNOSTIC_SOURCE; +}; + +const addSourceIfMissing = ( + diagnostic: Diagnostic, + source: string, +): Diagnostic => { + if (diagnostic.source) { + return diagnostic; + } + + return { + ...diagnostic, + source, + }; +}; + +const normalizeDiagnosticSources = ( + diagnostics: Diagnostic[], + source: string, +): Diagnostic[] => { + return diagnostics.map((diagnostic) => addSourceIfMissing(diagnostic, source)); +}; + export interface PlayerLanguageServicePlugin { /** The name of the plugin */ name: string; @@ -150,11 +191,114 @@ export class PlayerLanguageService { }) { // load base definitions? this.XLRService = new XLRService(); + this.wrapValidationHooksWithSourceAttribution(); PLUGINS.forEach((p) => p.apply(this)); config?.plugins?.forEach((p) => p.apply(this)); } + private createPluginScopedValidationContext( + tapName: unknown, + validationContext: ValidationContext, + ): ValidationContext { + const source = getTapSource(tapName); + + return { + useASTVisitor: (visitor) => validationContext.useASTVisitor(visitor), + addViolation: (violation) => { + validationContext.addViolation({ + ...violation, + source: violation.source ?? source, + }); + }, + addDiagnostic: (diagnostic) => { + validationContext.addDiagnostic(addSourceIfMissing(diagnostic, source)); + }, + }; + } + + private wrapValidationHooksWithSourceAttribution(): void { + const validateHook = this.hooks.validate as any; + const originalValidateTap = validateHook.tap.bind(validateHook); + validateHook.tap = (tapName: unknown, callback: any) => { + return originalValidateTap( + tapName, + async (ctx: DocumentContext, validationContext: ValidationContext) => { + return callback( + ctx, + this.createPluginScopedValidationContext(tapName, validationContext), + ); + }, + ); + }; + + if (typeof validateHook.tapPromise === "function") { + const originalValidateTapPromise = + validateHook.tapPromise.bind(validateHook); + validateHook.tapPromise = (tapName: unknown, callback: any) => { + return originalValidateTapPromise( + tapName, + (ctx: DocumentContext, validationContext: ValidationContext) => { + return callback( + ctx, + this.createPluginScopedValidationContext( + tapName, + validationContext, + ), + ); + }, + ); + }; + } + + if (typeof validateHook.tapAsync === "function") { + const originalValidateTapAsync = validateHook.tapAsync.bind(validateHook); + validateHook.tapAsync = (tapName: unknown, callback: any) => { + return originalValidateTapAsync( + tapName, + ( + ctx: DocumentContext, + validationContext: ValidationContext, + done: (...args: any[]) => void, + ) => { + callback( + ctx, + this.createPluginScopedValidationContext( + tapName, + validationContext, + ), + done, + ); + }, + ); + }; + } + + const onValidateEndHook = this.hooks.onValidateEnd as any; + const originalOnValidateEndTap = onValidateEndHook.tap.bind(onValidateEndHook); + onValidateEndHook.tap = (tapName: unknown, callback: any) => { + const source = getTapSource(tapName); + + return originalOnValidateEndTap( + tapName, + ( + diagnostics: Diagnostic[], + onValidateEndContext: { + documentContext: DocumentContext; + addFixableViolation: (diag: Diagnostic, violation: Violation) => void; + }, + ) => { + const updatedDiagnostics = callback(diagnostics, onValidateEndContext); + const diagnosticsToUse = Array.isArray(updatedDiagnostics) + ? updatedDiagnostics + : diagnostics; + + return normalizeDiagnosticSources(diagnosticsToUse, source); + }, + ); + }; + } + private parseTextDocument(document: TextDocument): PlayerContent { if (!this.parseCache.has(document.uri)) { const parsed = parse(document); @@ -277,7 +421,10 @@ export class PlayerLanguageService { return; } - const diagnostics = [...ctx.PlayerContent.syntaxErrors]; + const diagnostics = normalizeDiagnosticSources( + [...ctx.PlayerContent.syntaxErrors], + FALLBACK_DIAGNOSTIC_SOURCE, + ); const astVisitors: Array = []; /** Add a matching violation fix to the original diagnostic */ @@ -297,7 +444,7 @@ export class PlayerLanguageService { if (ctx.PlayerContent.root) { const validationContext: ValidationContext = { addViolation: (violation) => { - const { message, node, severity, fix } = violation; + const { message, node, severity, source, fix } = violation; const range: Range = toRange(document, node); @@ -305,6 +452,7 @@ export class PlayerLanguageService { message, severity, range, + source: source ?? FALLBACK_DIAGNOSTIC_SOURCE, }; if (fix) { @@ -317,7 +465,9 @@ export class PlayerLanguageService { astVisitors.push(visitor); }, addDiagnostic(d) { - diagnostics.push(d); + diagnostics.push( + addSourceIfMissing(d, FALLBACK_DIAGNOSTIC_SOURCE), + ); }, }; @@ -343,10 +493,15 @@ export class PlayerLanguageService { }); } - return this.hooks.onValidateEnd.call(diagnostics, { + const finalizedDiagnostics = this.hooks.onValidateEnd.call(diagnostics, { documentContext: ctx, addFixableViolation, - }); + }) as Diagnostic[]; + + return normalizeDiagnosticSources( + finalizedDiagnostics, + FALLBACK_DIAGNOSTIC_SOURCE, + ); } async getCompletionsAtPosition( diff --git a/language/json-language-service/src/parser/jsonParseErrors.ts b/language/json-language-service/src/parser/jsonParseErrors.ts index 138407b0..91c919a1 100644 --- a/language/json-language-service/src/parser/jsonParseErrors.ts +++ b/language/json-language-service/src/parser/jsonParseErrors.ts @@ -27,6 +27,8 @@ enum ParseErrorCode { InvalidCharacter = 16, } +export const JSON_PARSE_ERROR_SOURCE = "json-parse"; + /** Just what the function says */ export function prettyPrintParseErrorCode(code: ParseErrorCode): string { switch (code) { @@ -47,7 +49,7 @@ export function prettyPrintParseErrorCode(code: ParseErrorCode): string { case ParseErrorCode.UnexpectedEndOfString: return `Expected "`; default: - return printParseErrorCode(code as any); + return printParseErrorCode(code as unknown as number); } } @@ -62,8 +64,10 @@ export function convertErrorsToDiags( document.positionAt(parseError.offset), document.positionAt(parseError.offset + parseError.length), ), - prettyPrintParseErrorCode(parseError.error as any as ParseErrorCode), + prettyPrintParseErrorCode(parseError.error as unknown as ParseErrorCode), DiagnosticSeverity.Error, + undefined, + JSON_PARSE_ERROR_SOURCE, ); }); } diff --git a/language/json-language-service/src/types.ts b/language/json-language-service/src/types.ts index adb10be8..d37f99a6 100644 --- a/language/json-language-service/src/types.ts +++ b/language/json-language-service/src/types.ts @@ -99,6 +99,9 @@ export interface Violation { /** how much do we care? */ severity: DiagnosticSeverity; + /** Optional source label for the emitted diagnostic */ + source?: string; + /** A function that can make this good */ fix?: () => { /** the edit to apply */