-
Notifications
You must be signed in to change notification settings - Fork 15
/
Copy pathvalidation.ts
183 lines (167 loc) · 5.81 KB
/
validation.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
import type { EditorView, ViewUpdate } from "@codemirror/view";
import { type Diagnostic } from "@codemirror/lint";
import { Draft04, type Draft, type JsonError } from "json-schema-library";
import { getJSONSchema, schemaStateField } from "./state";
import { joinWithOr } from "../utils/formatting";
import { JSONMode, JSONPointerData, RequiredPick } from "../types";
import { el } from "../utils/dom";
import { renderMarkdown } from "../utils/markdown";
import { MODES } from "../constants";
import { debug } from "../utils/debug";
import { DocumentParser, getDefaultParser } from "../parsers";
// return an object path that matches with the json-source-map pointer
const getErrorPath = (error: JsonError): string => {
// if a pointer is present, return without #
if (error?.data?.pointer && error?.data?.pointer !== "#") {
return error.data.pointer.slice(1);
}
// return plain data.property if present
if (error?.data?.property) {
return `/${error.data.property}`;
}
// else, return the empty pointer to represent the whole document
return "";
};
export interface JSONValidationOptions {
mode?: JSONMode;
formatError?: (error: JsonError) => string;
jsonParser?: DocumentParser;
}
type JSONValidationSettings = RequiredPick<JSONValidationOptions, "jsonParser">;
export const handleRefresh = (vu: ViewUpdate) => {
return (
vu.startState.field(schemaStateField) !== vu.state.field(schemaStateField)
);
};
/**
* Helper for simpler class instantiaton
* @group Codemirror Extensions
*/
export function jsonSchemaLinter(options?: JSONValidationOptions) {
const validation = new JSONValidation(options);
return (view: EditorView) => {
return validation.doValidation(view);
};
}
// all the error types that apply to a specific key or value
const positionalErrors = [
"NoAdditionalPropertiesError",
"RequiredPropertyError",
"InvalidPropertyNameError",
"ForbiddenPropertyError",
"UndefinedValueError",
];
export class JSONValidation {
private schema: Draft | null = null;
private mode: JSONMode = MODES.JSON;
private parser: DocumentParser;
public constructor(private options?: JSONValidationOptions) {
this.mode = this.options?.mode ?? MODES.JSON;
this.parser = this.options?.jsonParser ?? getDefaultParser(this.mode);
// TODO: support other versions of json schema.
// most standard schemas are draft 4 for some reason, probably
// backwards compatibility
//
// ajv did not support draft 4, so I used json-schema-library
}
private get schemaTitle() {
return this.schema?.getSchema()?.title ?? "json-schema";
}
// rewrite the error message to be more human readable
private rewriteError = (error: JsonError): string => {
const errorData = error?.data;
const errors = errorData?.errors as string[];
if (error.code === "one-of-error" && errors?.length) {
return `Expected one of ${joinWithOr(
errors,
(data) => data.data.expected,
)}`;
}
if (error.code === "type-error") {
return `Expected \`${
error?.data?.expected && Array.isArray(error?.data?.expected)
? joinWithOr(error?.data?.expected)
: error?.data?.expected
}\` but received \`${error?.data?.received}\``;
}
const message = error.message
// don't mention root object
.replaceAll("in `#` ", "")
.replaceAll("at `#`", "")
.replaceAll("/", ".")
.replaceAll("#.", "");
return message;
};
// validate using view as the linter extension signature requires
public doValidation(view: EditorView) {
const schema = getJSONSchema(view.state);
if (!schema) {
return [];
}
this.schema = new Draft04(schema);
if (!this.schema) return [];
const text = view.state.doc.toString();
// ignore blank json strings
if (!text?.length) return [];
const json = this.parser(view.state);
// skip validation if parsing fails
if (json.data == null) return [];
let errors: JsonError[] = [];
try {
errors = this.schema.validate(json.data);
} catch {}
debug.log("xxx", "validation errors", errors, json.data);
if (!errors.length) return [];
// reduce() because we want to filter out errors that don't have a pointer
return errors.reduce<Diagnostic[]>((acc, error) => {
const pushRoot = () => {
const errorString = this.rewriteError(error);
acc.push({
from: 0,
to: 0,
message: errorString,
severity: "error",
source: this.schemaTitle,
renderMessage: () => {
const dom = el("div", {});
dom.innerHTML = renderMarkdown(errorString);
return dom;
},
});
};
const errorPath = getErrorPath(error);
const pointer = json.pointers.get(errorPath) as JSONPointerData;
if (
error.name === "MaxPropertiesError" ||
error.name === "MinPropertiesError" ||
errorPath === "" // root level type errors
) {
pushRoot();
} else if (pointer) {
// if the error is a property error, use the key position
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;
// skip error if no from/to value is found
if (to !== undefined && from !== undefined) {
acc.push({
from,
to,
message: errorString,
renderMessage: () => {
const dom = el("div", {});
dom.innerHTML = renderMarkdown(errorString);
return dom;
},
severity: "error",
source: this.schemaTitle,
});
}
} else {
pushRoot();
}
return acc;
}, []);
}
}