|
| 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 | +} |
0 commit comments