Skip to content

Commit 8e936b6

Browse files
committed
Update validation to be and use diagnostics instead
1 parent 0862775 commit 8e936b6

File tree

6 files changed

+260
-183
lines changed

6 files changed

+260
-183
lines changed

apps/lsp/src/custom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ async function codeViewDiagnostics(quarto: Quarto, context: CodeViewCellContext)
7272
context.filepath,
7373
context.language == "yaml" ? "yaml" : "script",
7474
context.code.join("\n"),
75-
Position.create(context.selection.start.line, context.selection.start.character),
75+
Position.create(0, 0),
7676
false
7777
);
7878

packages/editor-codemirror/src/behaviors/completion.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import md from "markdown-it";
4242

4343
import { editorLanguage } from "editor-core";
4444

45-
import { CodeViewCompletionContext, codeViewCompletionContext, LintItem } from "editor";
45+
import { CodeViewCompletionContext, codeViewCompletionContext } from "editor";
4646

4747
import { Behavior, BehaviorContext } from ".";
4848
import { escapeRegExpCharacters } from "core";
@@ -93,9 +93,6 @@ export function completionBehavior(behaviorContext: BehaviorContext): Behavior {
9393
}
9494
}
9595

96-
const diagnostics = getDiagnostics(context, cvContext, behaviorContext)
97-
console.log('DEBUG 2 DIAGNOSTICS!', diagnostics)
98-
9996
// get completions
10097
return getCompletions(context, cvContext, behaviorContext);
10198
}
@@ -105,17 +102,6 @@ export function completionBehavior(behaviorContext: BehaviorContext): Behavior {
105102
}
106103
}
107104

108-
async function getDiagnostics(
109-
context: CompletionContext,
110-
cvContext: CodeViewCompletionContext,
111-
behaviorContext: BehaviorContext
112-
): Promise<LintItem[] | undefined> {
113-
const diagnostics = await behaviorContext.pmContext.ui.codeview?.codeViewDiagnostics(cvContext);
114-
if (context.aborted || !diagnostics) return undefined;
115-
116-
return diagnostics;
117-
}
118-
119105
async function getCompletions(
120106
context: CompletionContext,
121107
cvContext: CodeViewCompletionContext,
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*
2+
* diagnostics.ts
3+
*
4+
* Copyright (C) 2022 by Posit Software, PBC
5+
*
6+
* Unless you have received this program directly from Posit Software pursuant
7+
* to the terms of a commercial license agreement with Posit Software, then
8+
* this program is licensed to you under the terms of version 3 of the
9+
* GNU Affero General Public License. This program is distributed WITHOUT
10+
* ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11+
* MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12+
* AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13+
*
14+
*/
15+
import { EditorView } from "@codemirror/view";
16+
import { Node } from "prosemirror-model";
17+
import { Behavior, BehaviorContext } from ".";
18+
19+
import { Decoration, DecorationSet } from "@codemirror/view"
20+
import { StateField, StateEffect } from "@codemirror/state"
21+
import { hoverTooltip } from "@codemirror/view"
22+
23+
import * as m from "@quarto/_mapped-string"
24+
import * as v from "@quarto/_json-validator"
25+
import * as j from "@quarto/_annotated-json"
26+
import { CodeViewCellContext, codeViewCellContext, kEndColumn, kEndRow, kStartColumn, kStartRow, LintItem } from "editor";
27+
import { lines } from "core";
28+
import { Position } from "vscode-languageserver-types";
29+
30+
const EMPTY_SELECTION = { start: Position.create(0, 0), end: Position.create(0, 0) }
31+
32+
export function diagnosticsBehavior(behaviorContext: BehaviorContext): Behavior {
33+
// don't provide behavior if we don't have validation
34+
if (!behaviorContext.pmContext.ui.codeview) {
35+
return {}
36+
}
37+
38+
return {
39+
extensions: [underlinedDrrorHoverTooltip],
40+
41+
async init(pmNode, cmView) {
42+
const language = behaviorContext.options.lang(pmNode, pmNode.textContent)
43+
if (language === null) return;
44+
if (language !== "yaml-frontmatter") return;
45+
46+
const filepath = behaviorContext.pmContext.ui.context.getDocumentPath();
47+
if (filepath === null) return;
48+
49+
const code = lines(pmNode.textContent)
50+
51+
// here we hand-craft an artisinal cellContext because `codeViewCellContext(..)`
52+
// seems to return undefined inside of init
53+
const cellContext = {
54+
filepath,
55+
language: 'yaml',
56+
code: code.map(line => !/^(---|\.\.\.)\s*$/.test(line) ? line : ""),
57+
cellBegin: 0,
58+
cellEnd: code.length - 1,
59+
selection: EMPTY_SELECTION
60+
}
61+
62+
const diagnostics = await getDiagnostics(cellContext, behaviorContext)
63+
if (!diagnostics) return
64+
65+
for (const error of diagnostics) {
66+
underline(cmView,
67+
// Note: strangely, `error[kStartColumn]` gives the text editor *row* index and vice versa;
68+
// so we have to pass `error[kStartColumn]` as row and vice versa.
69+
// Note: to get the correct index, `code` must not have delimiters stripped out
70+
rowColumnToIndex(code, [error[kStartColumn], error[kStartRow]]),
71+
// same here
72+
rowColumnToIndex(code, [error[kEndColumn], error[kEndRow]]),
73+
error.text
74+
)
75+
}
76+
},
77+
async pmUpdate(_, updatePmNode, cmView) {
78+
clearUnderlines(cmView)
79+
80+
// first attempt at doing validation using imported libraries prepared
81+
// by Carlos
82+
const validation = await getValidation(updatePmNode)
83+
console.log(validation)
84+
// for (const error of validation.errors)
85+
// underline(cmView,
86+
// error.violatingObject.start + 3,
87+
// error.violatingObject.end + 3,
88+
// '<div style="padding: 4px 11px; font-family: monospace;"><span style="color: red; font-size: 24px; vertical-align: text-bottom;">⚠︎</span> ' +
89+
// error.niceError.heading +
90+
// '</div><div style="border-bottom: 1px solid lightgrey; width: 100%;"></div><div style="color:darkgrey; padding: 4px 11px">' +
91+
// error.niceError.error.join('\n') +
92+
// '</div>'
93+
// )
94+
95+
const filepath = behaviorContext.pmContext.ui.context.getDocumentPath();
96+
if (filepath === null) return;
97+
98+
const cellContext = codeViewCellContext(filepath, behaviorContext.view.state);
99+
if (cellContext === undefined) return;
100+
console.log(cellContext)
101+
102+
const diagnostics = await getDiagnostics(cellContext, behaviorContext)
103+
if (!diagnostics) return
104+
105+
console.log('UPDATE DEBUG!!', cellContext)
106+
107+
const codeLines = lines(updatePmNode.textContent)
108+
for (const error of diagnostics) {
109+
underline(cmView,
110+
// strangely, `error[kStartColumn]` gives the visual *row* index and vice versa
111+
rowColumnToIndex(codeLines, [error[kStartColumn], error[kStartRow]]),
112+
rowColumnToIndex(codeLines, [error[kEndColumn], error[kEndRow]]),
113+
'<div style="padding: 4px 11px; font-family: monospace;"><span style="color: red; font-size: 24px; vertical-align: text-bottom;">⚠︎</span> ' +
114+
error.text +
115+
'</div>'
116+
)
117+
}
118+
}
119+
}
120+
}
121+
122+
async function getDiagnostics(
123+
cellContext: CodeViewCellContext,
124+
behaviorContext: BehaviorContext
125+
): Promise<LintItem[] | undefined> {
126+
const diagnostics = await behaviorContext.pmContext.ui.codeview?.codeViewDiagnostics(cellContext);
127+
if (!diagnostics) return undefined;
128+
129+
return diagnostics;
130+
}
131+
132+
//Check if there is an underline at position and display a tooltip there
133+
//We want to show the error message as well
134+
const underlinedDrrorHoverTooltip = hoverTooltip((view, pos) => {
135+
const f = view.state.field(underlineField, false)
136+
if (!f) return null
137+
138+
const rangeAndSpec = rangeAndSpecOfDecorationAtPos(pos, f)
139+
if (!rangeAndSpec) return null
140+
const { range: { from, to }, spec } = rangeAndSpec
141+
142+
return {
143+
pos: from,
144+
end: to,
145+
above: true,
146+
create() {
147+
let dom = document.createElement("div")
148+
Object.assign(dom.style, {
149+
"box-shadow": 'rgba(0, 0, 0, 0.16) 0px 0px 8px 2px',
150+
border: '1px solid lightgrey'
151+
})
152+
dom.innerHTML = '<div style="padding: 4px 11px; font-family: monospace;"><span style="color: red; font-size: 24px; vertical-align: text-bottom;">⚠︎</span> ' +
153+
spec.message +
154+
'</div>'
155+
return { dom }
156+
}
157+
}
158+
})
159+
160+
const schema3 = {
161+
"$id": "title-is-string",
162+
"type": "object",
163+
"properties": {
164+
"title": {
165+
"type": "string"
166+
}
167+
}
168+
} as v.Schema
169+
170+
// We use a heuristic to check if the
171+
// codeblock is yaml frontmatter, if it is then we validate the yaml and add
172+
// error underline decoration with an attached error message that is displayed
173+
// on hover via `validationErrorHoverTooltip`.
174+
const getValidation = async (pmNode: Node): Promise<{ result: j.JSONValue, errors: v.LocalizedError[] }> => {
175+
return new Promise((resolve) => {
176+
const t = pmNode.textContent.trim()
177+
if (t.startsWith('---')) {
178+
const [_, extractedYamlString] = t.split('---')
179+
const ttYamlString = m.asMappedString(extractedYamlString)
180+
const ttAnnotation = j.parse(ttYamlString)
181+
182+
v.withValidator(schema3, async (validator) => {
183+
resolve(await validator.validateParse(ttYamlString, ttAnnotation));
184+
});
185+
}
186+
})
187+
}
188+
189+
const addUnderline = StateEffect.define<{ from: number, to: number, message: string }>({
190+
map: ({ from, to, message }, change) => ({ from: change.mapPos(from), to: change.mapPos(to), message })
191+
})
192+
const removeUnderlines = StateEffect.define({
193+
map: () => { }
194+
})
195+
const underlineField = StateField.define<DecorationSet>({
196+
create() {
197+
return Decoration.none
198+
},
199+
update(underlines, tr) {
200+
underlines = underlines.map(tr.changes)
201+
for (let e of tr.effects) {
202+
if (e.is(addUnderline)) {
203+
underlines = underlines.update({
204+
add: [Decoration.mark({ class: "cm-underline", message: e.value.message }).range(e.value.from, e.value.to)]
205+
})
206+
}
207+
if (e.is(removeUnderlines)) {
208+
underlines = underlines.update({ filter: () => false })
209+
}
210+
}
211+
return underlines
212+
},
213+
provide: f => EditorView.decorations.from(f)
214+
})
215+
216+
const underlineTheme = EditorView.baseTheme({
217+
".cm-underline": {
218+
textDecoration: "underline dotted 2px red",
219+
}
220+
})
221+
const underline = (cmView: EditorView, from: number, to: number, message: string) => {
222+
const effects: StateEffect<unknown>[] = [addUnderline.of({ from, to, message })]
223+
if (!cmView.state.field(underlineField, false))
224+
effects.push(StateEffect.appendConfig.of([underlineField, underlineTheme]))
225+
cmView.dispatch({ effects })
226+
}
227+
const clearUnderlines = (cmView: EditorView) => {
228+
if (!!cmView.state.field(underlineField, false))
229+
cmView.dispatch({ effects: [removeUnderlines.of()] })
230+
}
231+
232+
// helper function for positionally picking data from a DecorationSet
233+
const rangeAndSpecOfDecorationAtPos = (pos: number, d: DecorationSet) => {
234+
let spec: any | undefined
235+
let from: number | undefined
236+
let to: number | undefined
237+
d.between(pos, pos, (decoFrom, decoTo, deco) => {
238+
if (decoFrom <= pos && pos < decoTo) {
239+
spec = deco.spec
240+
from = decoFrom
241+
to = decoTo
242+
return false
243+
}
244+
return undefined
245+
})
246+
return spec !== undefined ? { range: { from: from!, to: to! }, spec } : undefined
247+
}
248+
249+
function rowColumnToIndex(strs: string[], [row, col]: [number, number]): number {
250+
let index = 0
251+
for (let i = 0; i < col; i++) {
252+
index += strs[i].length + 1
253+
}
254+
return index + row
255+
}

packages/editor-codemirror/src/behaviors/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { completionBehavior } from './completion';
3535
import { yamlOptionBehavior } from './yamloption';
3636
import { toolbarBehavior } from './toolbar';
3737
import { diagramBehavior } from './diagram';
38-
import { validationBehavior } from './validation';
38+
import { diagnosticsBehavior } from './diagnostics';
3939

4040
export interface Behavior {
4141
extensions?: Extension[];
@@ -70,7 +70,7 @@ export function createBehaviors(context: BehaviorContext): Behavior[] {
7070
yamlOptionBehavior(context),
7171
toolbarBehavior(context),
7272
diagramBehavior(context),
73-
validationBehavior()
73+
diagnosticsBehavior(context)
7474
];
7575
behaviors.push(keyboardBehavior(context, behaviors.flatMap(behavior => behavior.keys || [])));
7676
return behaviors;

0 commit comments

Comments
 (0)