Skip to content
Open
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: 5 additions & 0 deletions .changeset/tidy-diagnostic-ranges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"codemirror-json-schema": patch
---

Fix diagnostic ranges for primitive and nested array items in JSON, JSON5, and YAML. Locate property values by their syntax nodes rather than fixed sibling offsets, so newer JSON grammars highlight the invalid value instead of its colon. Preserve property key ranges for object-valued properties and keep missing-required-property diagnostics on their containing values.
130 changes: 130 additions & 0 deletions src/features/__tests__/diagnostic-ranges.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { EditorView } from "@codemirror/view";
import type { JSONSchema7 } from "json-schema";
import { describe, expect, it } from "vitest";
import { MODES } from "../../constants";
import { JSONValidation } from "../validation";
import { getExtensions } from "./__helpers__";

const integerArray: JSONSchema7 = {
type: "array",
items: { type: "integer" },
};

const cases: {
name: string;
doc: string;
schema: JSONSchema7;
highlighted: string;
}[] = [
{
name: "a property value, not its colon",
doc: '{"count": "invalid"}',
schema: { type: "object", properties: { count: { type: "integer" } } },
highlighted: '"invalid"',
},
...['"invalid"', "1.5", "true", "null", "{}", "[]"].map((value) => ({
name: `an invalid array item (${value})`,
doc: `{"values": [0, ${value}, 2]}`,
schema: { type: "object", properties: { values: integerArray } },
highlighted: value,
})),
{
name: "a primitive item in a root array",
doc: '[0, "invalid", 2]',
schema: integerArray,
highlighted: '"invalid"',
},
{
name: "an item in a nested array",
doc: '{"groups": [[0], [1, "invalid"]]}',
schema: {
type: "object",
properties: { groups: { type: "array", items: integerArray } },
},
highlighted: '"invalid"',
},
{
name: "an invalid object value without losing its property range",
doc: '{"value": {"child": 1}}',
schema: { type: "object", properties: { value: { type: "string" } } },
highlighted: '{"child": 1}',
},
{
name: "an additional property key with an object value",
doc: '{"extra": {"child": 1}}',
schema: { type: "object", additionalProperties: false },
highlighted: '"extra"',
},
{
name: "an array object missing a required property",
doc: '[{"present": 1}]',
schema: {
type: "array",
items: { type: "object", required: ["missing"] },
},
highlighted: '{"present": 1}',
},
];

describe.each(Object.values(MODES))("diagnostic ranges (%s)", (mode) => {
const languageCases =
mode === MODES.YAML
? ([
{
name: "a block sequence item without its dash",
doc: "values:\n - 0\n - invalid\n - 2\n",
schema: { type: "object", properties: { values: integerArray } },
highlighted: "invalid",
},
{
name: "an item in a nested block sequence",
doc: "groups:\n - values:\n - 0\n - invalid\n",
schema: {
type: "object",
properties: {
groups: {
type: "array",
items: {
type: "object",
properties: { values: integerArray },
},
},
},
},
highlighted: "invalid",
},
] satisfies typeof cases)
: mode === MODES.JSON5
? ([
{
name: "a property value separated by comments",
doc: "{count /* key */: /* value */ 'invalid'}",
schema: {
type: "object",
properties: { count: { type: "integer" } },
},
highlighted: "'invalid'",
},
] satisfies typeof cases)
: [];
it.each([...cases, ...languageCases])(
"highlights $name",
({ doc, schema, highlighted }) => {
const view = new EditorView({
doc,
extensions: [getExtensions(mode, schema)],
});
try {
const diagnostics = new JSONValidation({ mode }).doValidation(view);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0]).toMatchObject({
from: doc.indexOf(highlighted),
to: doc.indexOf(highlighted) + highlighted.length,
severity: "error",
});
} finally {
view.destroy();
}
},
);
});
12 changes: 8 additions & 4 deletions src/features/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export function jsonSchemaLinter(options?: JSONValidationOptions) {
// all the error types that apply to a specific key or value
const positionalErrors = [
"NoAdditionalPropertiesError",
"RequiredPropertyError",
"InvalidPropertyNameError",
"ForbiddenPropertyError",
"UndefinedValueError",
Expand Down Expand Up @@ -154,11 +153,16 @@ export class JSONValidation {
) {
pushRoot();
} else if (pointer) {
// if the error is a property error, use the key position
// Highlight existing keys for property errors. Missing required keys
// have no range, so keep highlighting their containing value.
const isKeyError = positionalErrors.includes(error.name);
const errorString = this.rewriteError(error);
const from = isKeyError ? pointer.keyFrom : pointer.valueFrom;
const to = isKeyError ? pointer.keyTo : pointer.valueTo;
const from = isKeyError
? (pointer.keyFrom ?? pointer.valueFrom)
: pointer.valueFrom;
const to = isKeyError
? (pointer.keyTo ?? pointer.valueTo)
: pointer.valueTo;
// skip error if no from/to value is found
if (to !== undefined && from !== undefined) {
acc.push({
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export type JSONPartialPointerData = {
};

export type JSONPointerData = {
keyFrom: number;
keyTo: number;
keyFrom?: number;
keyTo?: number;
valueFrom: number;
valueTo: number;
};
Expand Down
119 changes: 75 additions & 44 deletions src/utils/__tests__/json-pointers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,59 @@ bar: 123
});
const pointer = jsonPointerForPosition(state, pos, 1, mode);
expect(pointer).toEqual(expected);
}
},
);
});

describe("getJsonPointers", () => {
it.each(Object.values(MODES))(
"escapes property names and preserves ranges for nested arrays (%s)",
(mode) => {
const doc = '{"a/b~c": [[0, "invalid"]]}';
const state = EditorState.create({
doc,
extensions: [getExtensions(mode)],
});
const pointers = getJsonPointers(state, mode);
expect(pointers.get("/a~1b~0c/0/1")).toEqual({
valueFrom: doc.indexOf('"invalid"'),
valueTo: doc.indexOf('"invalid"') + '"invalid"'.length,
});
expect(pointers.get("/a~1b~0c/0")).toEqual({
valueFrom: doc.indexOf('[0, "invalid"]'),
valueTo: doc.indexOf('[0, "invalid"]') + '[0, "invalid"]'.length,
});
},
);

it.each([
{ mode: MODES.JSON, doc: '{"value":' },
{ mode: MODES.JSON5, doc: "{value:" },
{ mode: MODES.YAML, doc: "value:" },
])(
"keeps key-only ranges for unfinished properties (%s)",
({ mode, doc }) => {
const state = EditorState.create({
doc,
extensions: [getExtensions(mode)],
});
const pointers = getJsonPointers(state, mode);
const key = mode === MODES.JSON ? '"value"' : "value";
expect(pointers.get("/value")).toEqual({
keyFrom: doc.indexOf(key),
keyTo: doc.indexOf(key) + key.length,
});
},
);

it.each([
{
doc: '{"object": { "foo": true }, "bar": 123, "baz": [1,2,3], "boop": [{"foo": true}]}',
mode: MODES.JSON,
expected: {
"": {
keyFrom: 0,
keyTo: 80,
valueFrom: 0,
valueTo: 80,
},
"/bar": {
keyFrom: 28,
Expand All @@ -119,25 +159,18 @@ describe("getJsonPointers", () => {
valueFrom: 47,
valueTo: 54,
},
// TODO: return pointers for all array indexes, not just objects
// "/baz/0": {
// keyFrom: 40,
// keyTo: 45,
// valueFrom: 47,
// valueTo: 55,
// },
"/baz/0": { valueFrom: 48, valueTo: 49 },
"/baz/1": { valueFrom: 50, valueTo: 51 },
"/baz/2": { valueFrom: 52, valueTo: 53 },
"/boop": {
keyFrom: 56,
keyTo: 62,
valueFrom: 64,
valueTo: 79,
},
"/boop/0": {
keyFrom: 65,
keyTo: 78,
// TODO: These look erroneous. There is no key-value pair for array items
valueFrom: 78,
valueTo: 79,
valueFrom: 65,
valueTo: 78,
},
"/boop/0/foo": {
keyFrom: 66,
Expand All @@ -146,8 +179,10 @@ describe("getJsonPointers", () => {
valueTo: 77,
},
"/object": {
keyFrom: 11,
keyTo: 26,
keyFrom: 1,
keyTo: 9,
valueFrom: 11,
valueTo: 26,
},
"/object/foo": {
keyFrom: 13,
Expand All @@ -162,8 +197,8 @@ describe("getJsonPointers", () => {
mode: MODES.JSON5,
expected: {
"": {
keyFrom: 0,
keyTo: 72,
valueFrom: 0,
valueTo: 72,
},
"/bar": {
keyFrom: 26,
Expand All @@ -177,22 +212,18 @@ describe("getJsonPointers", () => {
valueFrom: 43,
valueTo: 50,
},
// TODO: return pointers for all array indexes, not just objects
// "/baz/0": {
// keyFrom: 40,
// keyTo: 45,
// valueFrom: 47,
// valueTo: 55,
// },
"/baz/0": { valueFrom: 44, valueTo: 45 },
"/baz/1": { valueFrom: 46, valueTo: 47 },
"/baz/2": { valueFrom: 48, valueTo: 49 },
"/boop": {
keyFrom: 52,
keyTo: 56,
valueFrom: 58,
valueTo: 71,
},
"/boop/0": {
keyFrom: 59,
keyTo: 70,
valueFrom: 59,
valueTo: 70,
},
"/boop/0/foo": {
keyFrom: 60,
Expand All @@ -201,8 +232,10 @@ describe("getJsonPointers", () => {
valueTo: 69,
},
"/object": {
keyFrom: 11,
keyTo: 24,
keyFrom: 1,
keyTo: 9,
valueFrom: 11,
valueTo: 24,
},
"/object/foo": {
keyFrom: 13,
Expand All @@ -227,8 +260,8 @@ boop:
mode: MODES.YAML,
expected: {
"": {
keyFrom: 4,
keyTo: 75,
valueFrom: 0,
valueTo: 75,
},
"/bar": {
keyFrom: 24,
Expand All @@ -242,22 +275,18 @@ boop:
valueFrom: 40,
valueTo: 55,
},
// TODO: return pointers for all array indexes, not just objects
// "/baz/0": {
// keyFrom: 40,
// keyTo: 45,
// valueFrom: 47,
// valueTo: 55,
// },
"/baz/0": { valueFrom: 42, valueTo: 43 },
"/baz/1": { valueFrom: 48, valueTo: 49 },
"/baz/2": { valueFrom: 54, valueTo: 55 },
"/boop": {
keyFrom: 56,
keyTo: 60,
valueFrom: 64,
valueTo: 75,
},
"/boop/0": {
keyFrom: 66,
keyTo: 75,
valueFrom: 66,
valueTo: 75,
},
"/boop/0/foo": {
keyFrom: 66,
Expand All @@ -266,8 +295,10 @@ boop:
valueTo: 75,
},
"/object": {
keyFrom: 14,
keyTo: 23,
keyFrom: 4,
keyTo: 10,
valueFrom: 14,
valueTo: 23,
},
"/object/foo": {
keyFrom: 14,
Expand All @@ -286,6 +317,6 @@ boop:
});
const pointers = getJsonPointers(state, mode);
expect(Object.fromEntries(pointers.entries())).toEqual(expected);
}
},
);
});
Loading