Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"",
Expand All @@ -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"",
Expand All @@ -152,6 +154,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] =
},
},
"severity": 1,
"source": "xlr-plugin",
},
{
"message": "View is not reachable",
Expand All @@ -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'",
Expand All @@ -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",
Expand All @@ -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.",
Expand All @@ -208,6 +214,7 @@ exports[`player language service > validation > throws AssetWrapper errors 1`] =
},
},
"severity": 1,
"source": "asset-wrapper-to-array",
},
]
`;
50 changes: 50 additions & 0 deletions language/json-language-service/src/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
165 changes: 160 additions & 5 deletions language/json-language-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<ASTVisitor> = [];

/** Add a matching violation fix to the original diagnostic */
Expand All @@ -297,14 +444,15 @@ 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);

const diagnostic: Diagnostic = {
message,
severity,
range,
source: source ?? FALLBACK_DIAGNOSTIC_SOURCE,
};

if (fix) {
Expand All @@ -317,7 +465,9 @@ export class PlayerLanguageService {
astVisitors.push(visitor);
},
addDiagnostic(d) {
diagnostics.push(d);
diagnostics.push(
addSourceIfMissing(d, FALLBACK_DIAGNOSTIC_SOURCE),
);
},
};

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand All @@ -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,
);
});
}
3 changes: 3 additions & 0 deletions language/json-language-service/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down