diff --git a/frontend/components/Cell.js b/frontend/components/Cell.js index 247740152a..b43b843ae2 100644 --- a/frontend/components/Cell.js +++ b/frontend/components/Cell.js @@ -135,7 +135,31 @@ export const Cell = ({ const remount = useMemo(() => () => setKey(key + 1)) // cm_forced_focus is null, except when a line needs to be highlighted because it is part of a stack trace const [cm_forced_focus, set_cm_forced_focus] = useState(/** @type{any} */ (null)) + const [cm_highlighted_range, set_cm_highlighted_range] = useState(null) const [cm_highlighted_line, set_cm_highlighted_line] = useState(null) + const [cm_diagnostics, set_cm_diagnostics] = useState([]) + + useEffect(() => { + const diagnosticListener = (e) => { + if (e.detail.cell_id === cell_id) { + set_cm_diagnostics(e.detail.diagnostics) + } + } + window.addEventListener("cell_diagnostics", diagnosticListener) + return () => window.removeEventListener("cell_diagnostics", diagnosticListener) + }, [cell_id]) + + useEffect(() => { + const highlightRangeListener = (e) => { + if (e.detail.cell_id == cell_id && e.detail.from != null && e.detail.to != null) { + set_cm_highlighted_range({ from: e.detail.from, to: e.detail.to }) + } else { + set_cm_highlighted_range(null) + } + } + window.addEventListener("cell_highlight_range", highlightRangeListener) + return () => window.removeEventListener("cell_highlight_range", highlightRangeListener) + }, [cell_id]) useEffect(() => { const focusListener = (e) => { @@ -248,21 +272,21 @@ export const Cell = ({ key=${cell_key} ref=${node_ref} class=${cl({ - queued: queued || (waiting_to_run && is_process_ready), - running, - activate_animation, - errored, - selected, - code_differs: class_code_differs, - code_folded: class_code_folded, - skip_as_script, - running_disabled, - depends_on_disabled_cells, - depends_on_skipped_cells, - show_input, - shrunk: Object.values(logs).length > 0, - hooked_up: output?.has_pluto_hook_features ?? false, - })} + queued: queued || (waiting_to_run && is_process_ready), + running, + activate_animation, + errored, + selected, + code_differs: class_code_differs, + code_folded: class_code_folded, + skip_as_script, + running_disabled, + depends_on_disabled_cells, + depends_on_skipped_cells, + show_input, + shrunk: Object.values(logs).length > 0, + hooked_up: output?.has_pluto_hook_features ?? false, + })} id=${cell_id} > ${variables.map((name) => html``)} @@ -274,14 +298,14 @@ export const Cell = ({ - ${cell_api_ready ? html`<${CellOutput} errored=${errored} ...${output} cell_id=${cell_id} />` : html``} + ${cell_api_ready ? html`<${CellOutput} errored=${errored} ...${output} cell_id=${cell_id} />` : html``} <${CellInput} local_code=${cell_input_local?.code ?? code} remote_code=${code} @@ -308,7 +332,8 @@ export const Cell = ({ set_show_logs=${set_show_logs} set_cell_disabled=${set_cell_disabled} cm_highlighted_line=${cm_highlighted_line} - set_cm_highlighted_line=${set_cm_highlighted_line} + cm_highlighted_range=${cm_highlighted_range} + cm_diagnostics=${cm_diagnostics} onerror=${remount} /> ${show_logs && cell_api_ready @@ -320,8 +345,8 @@ export const Cell = ({ depends_on_disabled_cells=${depends_on_disabled_cells} on_run=${on_run} on_interrupt=${() => { - pluto_actions.interrupt_remote(cell_id) - }} + pluto_actions.interrupt_remote(cell_id) + }} set_cell_disabled=${set_cell_disabled} runtime=${runtime} running=${running} @@ -331,42 +356,42 @@ export const Cell = ({ /> ${skip_as_script - ? html`
{ - open_pluto_popup({ - type: "info", - source_element: e.target, - body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
+ open_pluto_popup({ + type: "info", + source_element: e.target, + body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
This way, it will not run when the notebook runs as a script outside of Pluto.
Use the context menu to enable it again`, - }) - }} + }) + }} >
` - : depends_on_skipped_cells + : depends_on_skipped_cells ? html`
{ - open_pluto_popup({ - type: "info", - source_element: e.target, - body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
+ open_pluto_popup({ + type: "info", + source_element: e.target, + body: html`This cell is currently stored in the notebook file as a Julia comment, instead of code.
This way, it will not run when the notebook runs as a script outside of Pluto.
An upstream cell is indirectly disabling in file this one; enable the upstream one to affect this cell.`, - }) - }} + }) + }} >
` : null} @@ -388,7 +413,7 @@ export const IsolatedCell = ({ cell_input: { cell_id, metadata }, cell_result: { return html` ${cell_api_ready ? html`<${CellOutput} ...${output} cell_id=${cell_id} />` : html``} - ${show_logs ? html`<${Logs} logs=${Object.values(logs)} line_heights=${[15]} set_cm_highlighted_line=${() => {}} />` : null} + ${show_logs ? html`<${Logs} logs=${Object.values(logs)} line_heights=${[15]} set_cm_highlighted_line=${() => { }} />` : null} ` } diff --git a/frontend/components/CellInput.js b/frontend/components/CellInput.js index 9133b305a2..33cdb5e2e8 100644 --- a/frontend/components/CellInput.js +++ b/frontend/components/CellInput.js @@ -48,6 +48,7 @@ import { pythonLanguage, syntaxHighlighting, cssLanguage, + setDiagnostics, } from "../imports/CodemirrorPlutoSetup.js" import { markdown, html as htmlLang, javascript, sqlLang, python, julia_mixed } from "./CellInput/mixedParsers.js" @@ -59,7 +60,7 @@ import { cell_movement_plugin, prevent_holding_a_key_from_doing_things_across_ce import { pluto_paste_plugin } from "./CellInput/pluto_paste_plugin.js" import { bracketMatching } from "./CellInput/block_matcher_plugin.js" import { cl } from "../common/ClassTable.js" -import { HighlightLineFacet, highlightLinePlugin } from "./CellInput/highlight_line.js" +import { HighlightLineFacet, HighlightRangeFacet, highlightLinePlugin, highlightRangePlugin } from "./CellInput/highlight_line.js" import { commentKeymap } from "./CellInput/comment_mixed_parsers.js" import { ScopeStateField } from "./CellInput/scopestate_statefield.js" import { mod_d_command } from "./CellInput/mod_d_command.js" @@ -373,8 +374,10 @@ export const CellInput = ({ set_show_logs, set_cell_disabled, cm_highlighted_line, + cm_highlighted_range, metadata, global_definition_locations, + cm_diagnostics, }) => { let pluto_actions = useContext(PlutoActionsContext) const { disabled: running_disabled, skip_as_script } = metadata @@ -384,6 +387,7 @@ export const CellInput = ({ set_error(null) throw to_throw } + const notebook_id_ref = useRef(notebook_id) notebook_id_ref.current = notebook_id @@ -394,6 +398,7 @@ export const CellInput = ({ let nbpkg_compartment = useCompartment(newcm_ref, NotebookpackagesFacet.of(nbpkg)) let global_definitions_compartment = useCompartment(newcm_ref, GlobalDefinitionsFacet.of(global_definition_locations)) let highlighted_line_compartment = useCompartment(newcm_ref, HighlightLineFacet.of(cm_highlighted_line)) + let highlighted_range_compartment = useCompartment(newcm_ref, HighlightRangeFacet.of(cm_highlighted_range)) let editable_compartment = useCompartment(newcm_ref, EditorState.readOnly.of(disable_input)) let on_change_compartment = useCompartment( @@ -589,9 +594,11 @@ export const CellInput = ({ // Compartments coming from react state/props nbpkg_compartment, highlighted_line_compartment, + highlighted_range_compartment, global_definitions_compartment, editable_compartment, highlightLinePlugin(), + highlightRangePlugin(), // This is waaaay in front of the keys it is supposed to override, // Which is necessary because it needs to run before *any* keymap, @@ -713,6 +720,12 @@ export const CellInput = ({ // Wowww this has been enabled for some time now... wonder if there are issues about this yet ;) - DRAL awesome_line_wrapping, + // Reset diagnostics on change + EditorView.updateListener.of((update) => { + if (!update.docChanged) return + update.view.dispatch(setDiagnostics(update.state, [])) + }), + on_change_compartment, // This is my weird-ass extension that checks the AST and shows you where @@ -778,6 +791,14 @@ export const CellInput = ({ } }, []) + useEffect(() => { + if (newcm_ref.current == null) return + const cm = newcm_ref.current + const diagnostics = cm_diagnostics + + cm.dispatch(setDiagnostics(cm.state, diagnostics)) + }, [cm_diagnostics]) + // Effect to apply "remote_code" to the cell when it changes... // ideally this won't be necessary as we'll have actual multiplayer, // or something to tell the user that the cell is out of sync. diff --git a/frontend/components/CellInput/highlight_line.js b/frontend/components/CellInput/highlight_line.js index f66346f878..d5938350d4 100644 --- a/frontend/components/CellInput/highlight_line.js +++ b/frontend/components/CellInput/highlight_line.js @@ -4,6 +4,10 @@ const highlighted_line = Decoration.line({ attributes: { class: "cm-highlighted-line" }, }) +const highlighted_range = Decoration.mark({ + attributes: { class: "cm-highlighted-range" }, +}) + /** * @param {EditorView} view */ @@ -17,6 +21,22 @@ function create_line_decorations(view) { return Decoration.set([highlighted_line.range(line.from, line.from)]) } +/** + * @param {EditorView} view + */ +function create_range_decorations(view) { + let range = view.state.facet(HighlightRangeFacet) + if (range == null) { + return Decoration.set([]) + } + let { from, to } = range + if (from < 0 || from == to) { + return Decoration.set([]) + } + + return Decoration.set([highlighted_range.range(from, to)]) +} + /** * @type Facet */ @@ -25,6 +45,14 @@ export const HighlightLineFacet = Facet.define({ compare: (a, b) => a === b, }) +/** + * @type Facet<{from: number, to: number}?, {from: number, to: number}?> + */ +export const HighlightRangeFacet = Facet.define({ + combine: (values) => values[0], + compare: (a, b) => a === b, +}) + export const highlightLinePlugin = () => ViewPlugin.fromClass( class { @@ -53,3 +81,33 @@ export const highlightLinePlugin = () => decorations: (v) => v.decorations, } ) + + +export const highlightRangePlugin = () => + ViewPlugin.fromClass( + class { + updateDecos(view) { + this.decorations = create_range_decorations(view) + } + + /** + * @param {EditorView} view + */ + constructor(view) { + this.decorations = Decoration.set([]) + this.updateDecos(view) + } + + /** + * @param {ViewUpdate} update + */ + update(update) { + if (update.docChanged || update.state.facet(HighlightRangeFacet) !== update.startState.facet(HighlightRangeFacet)) { + this.updateDecos(update.view) + } + } + }, + { + decorations: (v) => v.decorations, + } + ) diff --git a/frontend/components/CellInput/pluto_autocomplete.js b/frontend/components/CellInput/pluto_autocomplete.js index 2130d6a484..94885157fc 100644 --- a/frontend/components/CellInput/pluto_autocomplete.js +++ b/frontend/components/CellInput/pluto_autocomplete.js @@ -131,7 +131,11 @@ let update_docs_from_autocomplete_selection = (on_update_doc_query) => { // The nice thing about this is that we can use the resulting state from the transaction, // without updating the actual state of the editor. let result_transaction = update.state.update({ - changes: { from: selected_option.source.from, to: selected_option.source.to, insert: text_to_apply }, + changes: { + from: selected_option.source.from, + to: Math.min(selected_option.source.to, update.state.doc.length), + insert: text_to_apply, + }, }) // So we can use `get_selected_doc_from_state` on our virtual state diff --git a/frontend/components/CellOutput.js b/frontend/components/CellOutput.js index ff4188cf36..8eb7b4943a 100644 --- a/frontend/components/CellOutput.js +++ b/frontend/components/CellOutput.js @@ -1,6 +1,6 @@ import { html, Component, useRef, useLayoutEffect, useContext } from "../imports/Preact.js" -import { ErrorMessage } from "./ErrorMessage.js" +import { ErrorMessage, ParseError } from "./ErrorMessage.js" import { TreeView, TableView, DivElement } from "./TreeView.js" import { @@ -148,6 +148,9 @@ export const OutputBody = ({ mime, body, cell_id, persist_js_state = false, last case "application/vnd.pluto.table+object": return html`<${TableView} cell_id=${cell_id} body=${body} persist_js_state=${persist_js_state} />` break + case "application/vnd.pluto.parseerror+object": + return html`
<${ParseError} cell_id=${cell_id} ...${body} />
` + break case "application/vnd.pluto.stacktrace+object": return html`
<${ErrorMessage} cell_id=${cell_id} ...${body} />
` break diff --git a/frontend/components/ErrorMessage.js b/frontend/components/ErrorMessage.js index 173243ee9b..60e83a1515 100644 --- a/frontend/components/ErrorMessage.js +++ b/frontend/components/ErrorMessage.js @@ -1,5 +1,8 @@ import { PlutoActionsContext } from "../common/PlutoContext.js" -import { html, useContext, useState } from "../imports/Preact.js" +import { EditorState, EditorView, julia_andrey, lineNumbers, syntaxHighlighting } from "../imports/CodemirrorPlutoSetup.js" +import { html, useContext, useEffect, useLayoutEffect, useRef, useState } from "../imports/Preact.js" +import { pluto_syntax_colors } from "./CellInput.js" +import { Editor } from "./Editor.js" const StackFrameFilename = ({ frame, cell_id }) => { const sep_index = frame.file.indexOf("#==#") @@ -38,6 +41,43 @@ const Funccall = ({ frame }) => { const insert_commas_and_and = (/** @type {any[]} */ xs) => xs.flatMap((x, i) => (i === xs.length - 1 ? [x] : i === xs.length - 2 ? [x, " and "] : [x, ", "])) +export const ParseError = ({ cell_id, diagnostics }) => { + useEffect(() => { + window.dispatchEvent( + new CustomEvent("cell_diagnostics", { + detail: { + cell_id, + diagnostics, + }, + }) + ) + return () => window.dispatchEvent(new CustomEvent("cell_diagnostics", { detail: { cell_id, diagnostics: [] } })) + }, [diagnostics]) + + return html` + +

Syntax error

+
+
    + ${diagnostics.map( + ({ message, from, to, line }) => + html`
  1. // NOTE: this could be moved move to `StackFrameFilename` + window.dispatchEvent(new CustomEvent("cell_highlight_range", { detail: { cell_id, from, to }})) + } + onmouseleave=${() => + window.dispatchEvent(new CustomEvent("cell_highlight_range", { detail: { cell_id, from: null, to: null }})) + } + > + ${message}@ + <${StackFrameFilename} frame=${{file: "#==#" + cell_id, line}} cell_id=${cell_id} /> +
  2. `) + } +
+
+
+ `; +} + export const ErrorMessage = ({ msg, stacktrace, cell_id }) => { let pluto_actions = useContext(PlutoActionsContext) const default_rewriter = { @@ -62,9 +102,9 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id }) => { { - e.preventDefault() - pluto_actions.split_remote_cell(cell_id, boundaries, true) - }} + e.preventDefault() + pluto_actions.split_remote_cell(cell_id, boundaries, true) + }} >Split this cell into ${boundaries.length} cells, or

` @@ -137,6 +177,11 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id }) => { } }), }, + { + pattern: /^syntax: (.*)$/, + display: default_rewriter.display, + show_stacktrace: () => false, + }, { pattern: /^UndefVarError: (.*) not defined\.?$/, display: (/** @type{string} */ x) => { @@ -183,14 +228,14 @@ export const ErrorMessage = ({ msg, stacktrace, cell_id }) => { : html`
    ${stacktrace.map( - (frame) => - html`
  1. + (frame) => + html`
  2. <${Funccall} frame=${frame} /> @ <${StackFrameFilename} frame=${frame} cell_id=${cell_id} /> ${frame.inlined ? html`[inlined]` : null}
  3. ` - )} + )}
`} ` diff --git a/frontend/editor.css b/frontend/editor.css index c573b7c38c..73964d87d5 100644 --- a/frontend/editor.css +++ b/frontend/editor.css @@ -1232,6 +1232,7 @@ pluto-input .cm-editor .cm-line { transition: background-color 0.15s ease-in-out; } +pluto-input .cm-editor span.cm-highlighted-range, pluto-input .cm-editor .cm-line.cm-highlighted-line { background-color: #bdbdbd68; border-radius: 3px; @@ -1934,6 +1935,9 @@ pluto-runarea { border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; border-top: none; + /* One less than z-index for pluto-input .cm-editor. + Otherwise it gets on top of the tooltips */ + z-index: 19; } pluto-runarea > span { @@ -3048,6 +3052,12 @@ button.floating_back_button { border-radius: 4px; } +.cm-tooltip-lint { + font-family: "JuliaMono"; + font-size: 0.75rem; + z-index: 100; +} + .cm-tooltip-autocomplete { max-height: calc(20 * 16px); box-sizing: content-box; @@ -3185,10 +3195,10 @@ pluto-input .cm-editor .cm-content { padding: 2px 0px; } -.cm-editor .cm-selectionBackground { +.cm-editor .cm-scroller > .cm-selectionLayer .cm-selectionBackground { background: var(--cm-selection-background-blurred); } -.cm-editor.cm-focused .cm-selectionBackground { +.cm-editor.cm-focused .cm-scroller > .cm-selectionLayer .cm-selectionBackground { background: var(--cm-selection-background); } diff --git a/frontend/imports/CodemirrorPlutoSetup.d.ts b/frontend/imports/CodemirrorPlutoSetup.d.ts index 6919393ed4..4e298e6d9d 100644 --- a/frontend/imports/CodemirrorPlutoSetup.d.ts +++ b/frontend/imports/CodemirrorPlutoSetup.d.ts @@ -1865,6 +1865,17 @@ declare type Attrs = { [name: string]: string; }; +/** +Basic rectangle type. +*/ +interface Rect { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; +} +declare type ScrollStrategy = "nearest" | "start" | "end" | "center"; + interface MarkDecorationSpec { /** Whether the mark covers its start and end position or not. This @@ -2013,7 +2024,7 @@ declare abstract class WidgetType { couldn't (in which case the widget will be redrawn). The default implementation just returns false. */ - updateDOM(dom: HTMLElement): boolean; + updateDOM(dom: HTMLElement, view: EditorView): boolean; /** The estimated height this widget will have, to be used when estimating the height of content that hasn't been drawn. May @@ -2028,6 +2039,14 @@ declare abstract class WidgetType { */ ignoreEvent(event: Event): boolean; /** + Override the way screen coordinates for positions at/in the + widget are found. `pos` will be the offset into the widget, and + `side` the side of the position that is being queriedβ€”less than + zero for before, greater than zero for after, and zero for + directly at that position. + */ + coordsAt(dom: HTMLElement, pos: number, side: number): Rect | null; + /** This is called when the an instance of the widget is removed from the editor view. */ @@ -2130,17 +2149,6 @@ declare abstract class Decoration extends RangeValue { static none: DecorationSet; } -/** -Basic rectangle type. -*/ -interface Rect { - readonly left: number; - readonly right: number; - readonly top: number; - readonly bottom: number; -} -declare type ScrollStrategy = "nearest" | "start" | "end" | "center"; - /** Command functions are used in key bindings and other types of user actions. Given an editor view, they check whether their effect can @@ -2841,6 +2849,11 @@ declare class EditorView { */ static inputHandler: Facet<(view: EditorView, from: number, to: number, text: string) => boolean, readonly ((view: EditorView, from: number, to: number, text: string) => boolean)[]>; /** + This facet can be used to provide functions that create effects + to be dispatched when the editor's focus state changes. + */ + static focusChangeEffect: Facet<(state: EditorState, focusing: boolean) => StateEffect | null, readonly ((state: EditorState, focusing: boolean) => StateEffect | null)[]>; + /** By default, the editor assumes all its content has the same [text direction](https://codemirror.net/6/docs/ref/#view.Direction). Configure this with a `true` value to make it read the text direction of every (rendered) @@ -3216,99 +3229,458 @@ Create a line number gutter extension. */ declare function lineNumbers(config?: LineNumberConfig): Extension; +/** +Highlighting tags are markers that denote a highlighting category. +They are [associated](#highlight.styleTags) with parts of a syntax +tree by a language mode, and then mapped to an actual CSS style by +a [highlighter](#highlight.Highlighter). + +Because syntax tree node types and highlight styles have to be +able to talk the same language, CodeMirror uses a mostly _closed_ +[vocabulary](#highlight.tags) of syntax tags (as opposed to +traditional open string-based systems, which make it hard for +highlighting themes to cover all the tokens produced by the +various languages). + +It _is_ possible to [define](#highlight.Tag^define) your own +highlighting tags for system-internal use (where you control both +the language package and the highlighter), but such tags will not +be picked up by regular highlighters (though you can derive them +from standard tags to allow highlighters to fall back to those). +*/ declare class Tag { + /** + The set of this tag and all its parent tags, starting with + this one itself and sorted in order of decreasing specificity. + */ readonly set: Tag[]; + /** + Define a new tag. If `parent` is given, the tag is treated as a + sub-tag of that parent, and + [highlighters](#highlight.tagHighlighter) that don't mention + this tag will try to fall back to the parent tag (or grandparent + tag, etc). + */ static define(parent?: Tag): Tag; + /** + Define a tag _modifier_, which is a function that, given a tag, + will return a tag that is a subtag of the original. Applying the + same modifier to a twice tag will return the same value (`m1(t1) + == m1(t1)`) and applying multiple modifiers will, regardless or + order, produce the same tag (`m1(m2(t1)) == m2(m1(t1))`). + + When multiple modifiers are applied to a given base tag, each + smaller set of modifiers is registered as a parent, so that for + example `m1(m2(m3(t1)))` is a subtype of `m1(m2(t1))`, + `m1(m3(t1)`, and so on. + */ static defineModifier(): (tag: Tag) => Tag; } +/** +A highlighter defines a mapping from highlighting tags and +language scopes to CSS class names. They are usually defined via +[`tagHighlighter`](#highlight.tagHighlighter) or some wrapper +around that, but it is also possible to implement them from +scratch. +*/ interface Highlighter { + /** + Get the set of classes that should be applied to the given set + of highlighting tags, or null if this highlighter doesn't assign + a style to the tags. + */ style(tags: readonly Tag[]): string | null; + /** + When given, the highlighter will only be applied to trees on + whose [top](#common.NodeType.isTop) node this predicate returns + true. + */ scope?(node: NodeType): boolean; } +/** +The default set of highlighting [tags](#highlight.Tag). + +This collection is heavily biased towards programming languages, +and necessarily incomplete. A full ontology of syntactic +constructs would fill a stack of books, and be impractical to +write themes for. So try to make do with this set. If all else +fails, [open an +issue](https://github.com/codemirror/codemirror.next) to propose a +new tag, or [define](#highlight.Tag^define) a local custom tag for +your use case. + +Note that it is not obligatory to always attach the most specific +tag possible to an elementβ€”if your grammar can't easily +distinguish a certain type of element (such as a local variable), +it is okay to style it as its more general variant (a variable). + +For tags that extend some parent tag, the documentation links to +the parent. +*/ declare const tags: { + /** + A comment. + */ comment: Tag; + /** + A line [comment](#highlight.tags.comment). + */ lineComment: Tag; + /** + A block [comment](#highlight.tags.comment). + */ blockComment: Tag; + /** + A documentation [comment](#highlight.tags.comment). + */ docComment: Tag; + /** + Any kind of identifier. + */ name: Tag; + /** + The [name](#highlight.tags.name) of a variable. + */ variableName: Tag; + /** + A type [name](#highlight.tags.name). + */ typeName: Tag; + /** + A tag name (subtag of [`typeName`](#highlight.tags.typeName)). + */ tagName: Tag; + /** + A property or field [name](#highlight.tags.name). + */ propertyName: Tag; + /** + An attribute name (subtag of [`propertyName`](#highlight.tags.propertyName)). + */ attributeName: Tag; + /** + The [name](#highlight.tags.name) of a class. + */ className: Tag; + /** + A label [name](#highlight.tags.name). + */ labelName: Tag; + /** + A namespace [name](#highlight.tags.name). + */ namespace: Tag; + /** + The [name](#highlight.tags.name) of a macro. + */ macroName: Tag; + /** + A literal value. + */ literal: Tag; + /** + A string [literal](#highlight.tags.literal). + */ string: Tag; + /** + A documentation [string](#highlight.tags.string). + */ docString: Tag; + /** + A character literal (subtag of [string](#highlight.tags.string)). + */ character: Tag; + /** + An attribute value (subtag of [string](#highlight.tags.string)). + */ attributeValue: Tag; + /** + A number [literal](#highlight.tags.literal). + */ number: Tag; + /** + An integer [number](#highlight.tags.number) literal. + */ integer: Tag; + /** + A floating-point [number](#highlight.tags.number) literal. + */ float: Tag; + /** + A boolean [literal](#highlight.tags.literal). + */ bool: Tag; + /** + Regular expression [literal](#highlight.tags.literal). + */ regexp: Tag; + /** + An escape [literal](#highlight.tags.literal), for example a + backslash escape in a string. + */ escape: Tag; + /** + A color [literal](#highlight.tags.literal). + */ color: Tag; + /** + A URL [literal](#highlight.tags.literal). + */ url: Tag; + /** + A language keyword. + */ keyword: Tag; + /** + The [keyword](#highlight.tags.keyword) for the self or this + object. + */ self: Tag; + /** + The [keyword](#highlight.tags.keyword) for null. + */ null: Tag; + /** + A [keyword](#highlight.tags.keyword) denoting some atomic value. + */ atom: Tag; + /** + A [keyword](#highlight.tags.keyword) that represents a unit. + */ unit: Tag; + /** + A modifier [keyword](#highlight.tags.keyword). + */ modifier: Tag; + /** + A [keyword](#highlight.tags.keyword) that acts as an operator. + */ operatorKeyword: Tag; + /** + A control-flow related [keyword](#highlight.tags.keyword). + */ controlKeyword: Tag; + /** + A [keyword](#highlight.tags.keyword) that defines something. + */ definitionKeyword: Tag; + /** + A [keyword](#highlight.tags.keyword) related to defining or + interfacing with modules. + */ moduleKeyword: Tag; + /** + An operator. + */ operator: Tag; + /** + An [operator](#highlight.tags.operator) that dereferences something. + */ derefOperator: Tag; + /** + Arithmetic-related [operator](#highlight.tags.operator). + */ arithmeticOperator: Tag; + /** + Logical [operator](#highlight.tags.operator). + */ logicOperator: Tag; + /** + Bit [operator](#highlight.tags.operator). + */ bitwiseOperator: Tag; + /** + Comparison [operator](#highlight.tags.operator). + */ compareOperator: Tag; + /** + [Operator](#highlight.tags.operator) that updates its operand. + */ updateOperator: Tag; + /** + [Operator](#highlight.tags.operator) that defines something. + */ definitionOperator: Tag; + /** + Type-related [operator](#highlight.tags.operator). + */ typeOperator: Tag; + /** + Control-flow [operator](#highlight.tags.operator). + */ controlOperator: Tag; + /** + Program or markup punctuation. + */ punctuation: Tag; + /** + [Punctuation](#highlight.tags.punctuation) that separates + things. + */ separator: Tag; + /** + Bracket-style [punctuation](#highlight.tags.punctuation). + */ bracket: Tag; + /** + Angle [brackets](#highlight.tags.bracket) (usually `<` and `>` + tokens). + */ angleBracket: Tag; + /** + Square [brackets](#highlight.tags.bracket) (usually `[` and `]` + tokens). + */ squareBracket: Tag; + /** + Parentheses (usually `(` and `)` tokens). Subtag of + [bracket](#highlight.tags.bracket). + */ paren: Tag; + /** + Braces (usually `{` and `}` tokens). Subtag of + [bracket](#highlight.tags.bracket). + */ brace: Tag; + /** + Content, for example plain text in XML or markup documents. + */ content: Tag; + /** + [Content](#highlight.tags.content) that represents a heading. + */ heading: Tag; + /** + A level 1 [heading](#highlight.tags.heading). + */ heading1: Tag; + /** + A level 2 [heading](#highlight.tags.heading). + */ heading2: Tag; + /** + A level 3 [heading](#highlight.tags.heading). + */ heading3: Tag; + /** + A level 4 [heading](#highlight.tags.heading). + */ heading4: Tag; + /** + A level 5 [heading](#highlight.tags.heading). + */ heading5: Tag; + /** + A level 6 [heading](#highlight.tags.heading). + */ heading6: Tag; + /** + A prose separator (such as a horizontal rule). + */ contentSeparator: Tag; + /** + [Content](#highlight.tags.content) that represents a list. + */ list: Tag; + /** + [Content](#highlight.tags.content) that represents a quote. + */ quote: Tag; + /** + [Content](#highlight.tags.content) that is emphasized. + */ emphasis: Tag; + /** + [Content](#highlight.tags.content) that is styled strong. + */ strong: Tag; + /** + [Content](#highlight.tags.content) that is part of a link. + */ link: Tag; + /** + [Content](#highlight.tags.content) that is styled as code or + monospace. + */ monospace: Tag; + /** + [Content](#highlight.tags.content) that has a strike-through + style. + */ strikethrough: Tag; + /** + Inserted text in a change-tracking format. + */ inserted: Tag; + /** + Deleted text. + */ deleted: Tag; + /** + Changed text. + */ changed: Tag; + /** + An invalid or unsyntactic element. + */ invalid: Tag; + /** + Metadata or meta-instruction. + */ meta: Tag; + /** + [Metadata](#highlight.tags.meta) that applies to the entire + document. + */ documentMeta: Tag; + /** + [Metadata](#highlight.tags.meta) that annotates or adds + attributes to a given syntactic element. + */ annotation: Tag; + /** + Processing instruction or preprocessor directive. Subtag of + [meta](#highlight.tags.meta). + */ processingInstruction: Tag; + /** + [Modifier](#highlight.Tag^defineModifier) that indicates that a + given element is being defined. Expected to be used with the + various [name](#highlight.tags.name) tags. + */ definition: (tag: Tag) => Tag; + /** + [Modifier](#highlight.Tag^defineModifier) that indicates that + something is constant. Mostly expected to be used with + [variable names](#highlight.tags.variableName). + */ constant: (tag: Tag) => Tag; + /** + [Modifier](#highlight.Tag^defineModifier) used to indicate that + a [variable](#highlight.tags.variableName) or [property + name](#highlight.tags.propertyName) is being called or defined + as a function. + */ function: (tag: Tag) => Tag; + /** + [Modifier](#highlight.Tag^defineModifier) that can be applied to + [names](#highlight.tags.name) to indicate that they belong to + the language's standard environment. + */ standard: (tag: Tag) => Tag; + /** + [Modifier](#highlight.Tag^defineModifier) that indicates a given + [names](#highlight.tags.name) is local to some scope. + */ local: (tag: Tag) => Tag; + /** + A generic variant [modifier](#highlight.Tag^defineModifier) that + can be used to tag language-specific alternative variants of + some common tag. It is recommended for themes to define special + forms of at least the [string](#highlight.tags.string) and + [variable name](#highlight.tags.variableName) tags, since those + come up a lot. + */ special: (tag: Tag) => Tag; }; @@ -3561,9 +3933,9 @@ declare class LanguageDescription { static matchLanguageName(descs: readonly LanguageDescription[], name: string, fuzzy?: boolean): LanguageDescription | null; } /** -Facet for overriding the unit by which indentation happens. -Should be a string consisting either entirely of spaces or -entirely of tabs. When not set, this defaults to 2 spaces. +Facet for overriding the unit by which indentation happens. Should +be a string consisting either entirely of the same whitespace +character. When not set, this defaults to 2 spaces. */ declare const indentUnit: Facet; /** @@ -3844,7 +4216,7 @@ The default keymap. Includes all bindings from - Shift-Alt-ArrowUp: [`copyLineUp`](https://codemirror.net/6/docs/ref/#commands.copyLineUp) - Shift-Alt-ArrowDown: [`copyLineDown`](https://codemirror.net/6/docs/ref/#commands.copyLineDown) - Escape: [`simplifySelection`](https://codemirror.net/6/docs/ref/#commands.simplifySelection) -- Ctrl-Enter (Comd-Enter on macOS): [`insertBlankLine`](https://codemirror.net/6/docs/ref/#commands.insertBlankLine) +- Ctrl-Enter (Cmd-Enter on macOS): [`insertBlankLine`](https://codemirror.net/6/docs/ref/#commands.insertBlankLine) - Alt-l (Ctrl-l on macOS): [`selectLine`](https://codemirror.net/6/docs/ref/#commands.selectLine) - Ctrl-i (Cmd-i on macOS): [`selectParentSyntax`](https://codemirror.net/6/docs/ref/#commands.selectParentSyntax) - Ctrl-[ (Cmd-[ on macOS): [`indentLess`](https://codemirror.net/6/docs/ref/#commands.indentLess) @@ -3933,6 +4305,19 @@ interface CompletionConfig { position: number; }[]; /** + By default, [info](https://codemirror.net/6/docs/ref/#autocomplet.Completion.info) tooltips are + placed to the side of the selected. This option can be used to + override that. It will be given rectangles for the list of + completions, the selected option, the info element, and the + availble [tooltip space](https://codemirror.net/6/docs/ref/#view.tooltips^config.tooltipSpace), + and should return style and/or class strings for the info + element. + */ + positionInfo?: (view: EditorView, list: Rect, option: Rect, info: Rect, space: Rect) => { + style?: string; + class?: string; + }; + /** The comparison function to use when sorting completions with the same match score. Defaults to using [`localeCompare`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare). @@ -3997,6 +4382,39 @@ interface Completion { down the list, a positive number moves it up. */ boost?: number; + /** + Can be used to divide the completion list into sections. + Completions in a given section (matched by name) will be grouped + together, with a heading above them. Options without section + will appear above all sections. A string value is equivalent to + a `{name}` object. + */ + section?: string | CompletionSection; +} +/** +Object used to describe a completion +[section](https://codemirror.net/6/docs/ref/#autocomplete.Completion.section). It is recommended to +create a shared object used by all the completions in a given +section. +*/ +interface CompletionSection { + /** + The name of the section. If no `render` method is present, this + will be displayed above the options. + */ + name: string; + /** + An optional function that renders the section header. Since the + headers are shown inside a list, you should make sure the + resulting element has a `display: list-item` style. + */ + header?: (section: CompletionSection) => HTMLElement; + /** + By default, sections are ordered alphabetically by name. To + specify an explicit order, `rank` can be used. Sections with a + lower rank will be shown above sections with a higher rank. + */ + rank?: number; } /** An instance of this is passed to completion source functions. @@ -4192,7 +4610,7 @@ interpreted as indicating a placeholder. declare function snippet(template: string): (editor: { state: EditorState; dispatch: (tr: Transaction) => void; -}, _completion: Completion, from: number, to: number) => void; +}, completion: Completion, from: number, to: number) => void; /** A command that clears the active snippet, if any. */ @@ -4347,6 +4765,7 @@ type index_d_Completion = Completion; type index_d_CompletionContext = CompletionContext; declare const index_d_CompletionContext: typeof CompletionContext; type index_d_CompletionResult = CompletionResult; +type index_d_CompletionSection = CompletionSection; type index_d_CompletionSource = CompletionSource; declare const index_d_acceptCompletion: typeof acceptCompletion; declare const index_d_autocompletion: typeof autocompletion; @@ -4381,6 +4800,7 @@ declare namespace index_d { index_d_Completion as Completion, index_d_CompletionContext as CompletionContext, index_d_CompletionResult as CompletionResult, + index_d_CompletionSection as CompletionSection, index_d_CompletionSource as CompletionSource, index_d_acceptCompletion as acceptCompletion, index_d_autocompletion as autocompletion, @@ -4697,6 +5117,101 @@ declare function html(config?: { nestedAttributes?: NestedAttr[]; }): LanguageSupport; +/** +Describes a problem or hint for a piece of code. +*/ +interface Diagnostic { + /** + The start position of the relevant text. + */ + from: number; + /** + The end position. May be equal to `from`, though actually + covering text is preferable. + */ + to: number; + /** + The severity of the problem. This will influence how it is + displayed. + */ + severity: "info" | "warning" | "error"; + /** + An optional source string indicating where the diagnostic is + coming from. You can put the name of your linter here, if + applicable. + */ + source?: string; + /** + The message associated with this diagnostic. + */ + message: string; + /** + An optional custom rendering function that displays the message + as a DOM node. + */ + renderMessage?: () => Node; + /** + An optional array of actions that can be taken on this + diagnostic. + */ + actions?: readonly Action[]; +} +/** +An action associated with a diagnostic. +*/ +interface Action { + /** + The label to show to the user. Should be relatively short. + */ + name: string; + /** + The function to call when the user activates this action. Is + given the diagnostic's _current_ position, which may have + changed since the creation of the diagnostic, due to editing. + */ + apply: (view: EditorView, from: number, to: number) => void; +} +declare type DiagnosticFilter = (diagnostics: readonly Diagnostic[]) => Diagnostic[]; +interface LintConfig { + /** + Time to wait (in milliseconds) after a change before running + the linter. Defaults to 750ms. + */ + delay?: number; + /** + Optional predicate that can be used to indicate when diagnostics + need to be recomputed. Linting is always re-done on document + changes. + */ + needsRefresh?: null | ((update: ViewUpdate) => boolean); + /** + Optional filter to determine which diagnostics produce markers + in the content. + */ + markerFilter?: null | DiagnosticFilter; + /** + Filter applied to a set of diagnostics shown in a tooltip. No + tooltip will appear if the empty set is returned. + */ + tooltipFilter?: null | DiagnosticFilter; +} +/** +Returns a transaction spec which updates the current set of +diagnostics, and enables the lint extension if if wasn't already +active. +*/ +declare function setDiagnostics(state: EditorState, diagnostics: readonly Diagnostic[]): TransactionSpec; +/** +The type of a function that produces diagnostics. +*/ +declare type LintSource = (view: EditorView) => readonly Diagnostic[] | Promise; +/** +Given a diagnostic source, this function returns an extension that +enables linting with that source. It will be called whenever the +editor is idle (after its content changed). +*/ +declare function linter(source: LintSource, config?: LintConfig): Extension; + /** A language provider based on the [Lezer JavaScript parser](https://github.com/lezer-parser/javascript), extended with @@ -4832,6 +5347,12 @@ interface SQLConfig { */ tables?: readonly Completion[]; /** + Similar to `tables`, if you want to provide completion objects + for your schemas rather than using the generated ones, pass them + here. + */ + schemas?: readonly Completion[]; + /** When given, columns from the named table can be completed directly at the top level. */ @@ -4893,4 +5414,4 @@ Create an instance of the collaborative editing plugin. */ declare function collab(config?: CollabConfig): Extension; -export { Annotation, Compartment, Decoration, EditorSelection, EditorState, EditorView, Facet, HighlightStyle, NodeProp, PostgreSQL, SelectionRange, StateEffect, StateField, Text, Transaction, TreeCursor, ViewPlugin, ViewUpdate, WidgetType, index_d as autocomplete, bracketMatching, closeBrackets, closeBracketsKeymap, collab, combineConfig, completionKeymap, css, cssLanguage, defaultHighlightStyle, defaultKeymap, drawSelection, foldGutter, foldKeymap, highlightSelectionMatches, highlightSpecialChars, history, historyKeymap, html, htmlLanguage, indentLess, indentMore, indentOnInput, indentUnit, javascript, javascriptLanguage, julia as julia_andrey, keymap, lineNumbers, markdown, markdownLanguage, parseCode, parseMixed, placeholder, python, pythonLanguage, rectangularSelection, searchKeymap, selectNextOccurrence, sql, syntaxHighlighting, syntaxTree, syntaxTreeAvailable, tags }; +export { Annotation, Compartment, Decoration, Diagnostic, EditorSelection, EditorState, EditorView, Facet, HighlightStyle, NodeProp, PostgreSQL, SelectionRange, StateEffect, StateField, Text, Transaction, TreeCursor, ViewPlugin, ViewUpdate, WidgetType, index_d as autocomplete, bracketMatching, closeBrackets, closeBracketsKeymap, collab, combineConfig, completionKeymap, css, cssLanguage, defaultHighlightStyle, defaultKeymap, drawSelection, foldGutter, foldKeymap, highlightSelectionMatches, highlightSpecialChars, history, historyKeymap, html, htmlLanguage, indentLess, indentMore, indentOnInput, indentUnit, javascript, javascriptLanguage, julia as julia_andrey, keymap, lineNumbers, linter, markdown, markdownLanguage, parseCode, parseMixed, placeholder, python, pythonLanguage, rectangularSelection, searchKeymap, selectNextOccurrence, setDiagnostics, sql, syntaxHighlighting, syntaxTree, syntaxTreeAvailable, tags }; diff --git a/frontend/imports/CodemirrorPlutoSetup.js b/frontend/imports/CodemirrorPlutoSetup.js index ce8c635152..da54f20909 100644 --- a/frontend/imports/CodemirrorPlutoSetup.js +++ b/frontend/imports/CodemirrorPlutoSetup.js @@ -59,10 +59,14 @@ import { css, cssLanguage, selectNextOccurrence, + linter, + setDiagnostics, //@ts-ignore -} from "https://cdn.jsdelivr.net/gh/JuliaPluto/codemirror-pluto-setup@0.28.4/dist/index.es.min.js" +} from "https://cdn.jsdelivr.net/gh/JuliaPluto/codemirror-pluto-setup@1234.0.0/dist/index.es.min.js" export { + linter, + setDiagnostics, EditorState, EditorSelection, Compartment, diff --git a/src/analysis/ExpressionExplorer.jl b/src/analysis/ExpressionExplorer.jl index 1fd4b5837c..67edc5c82a 100644 --- a/src/analysis/ExpressionExplorer.jl +++ b/src/analysis/ExpressionExplorer.jl @@ -1348,6 +1348,7 @@ function can_be_function_wrapped(x::Expr) x.head === :using || x.head === :import || x.head === :module || + x.head === :incomplete || # Only bail on named functions, but anonymous functions (args[1].head == :tuple) are fine. # TODO Named functions INSIDE other functions should be fine too (x.head === :function && !Meta.isexpr(x.args[1], :tuple)) || diff --git a/src/analysis/Parse.jl b/src/analysis/Parse.jl index 200b5faf4b..b3ac5162d5 100644 --- a/src/analysis/Parse.jl +++ b/src/analysis/Parse.jl @@ -1,4 +1,5 @@ import .ExpressionExplorer +import Markdown "Generate a file name to be given to the parser (will show up in stack traces)." pluto_filename(notebook::Notebook, cell::Cell)::String = notebook.path * "#==#" * string(cell.cell_id) @@ -19,7 +20,7 @@ function parse_custom(notebook::Notebook, cell::Cell)::Expr raw = if can_insert_filename filename = pluto_filename(notebook, cell) ex = Base.parse_input_line(cell.code, filename=filename) - if (ex isa Expr) && (ex.head == :toplevel) + if Meta.isexpr(ex, :toplevel) # if there is more than one expression: if count(a -> !(a isa LineNumberNode), ex.args) > 1 Expr(:error, "extra token after end of expression\n\nBoundaries: $(expression_boundaries(cell.code))") @@ -100,12 +101,14 @@ Make some small adjustments to the `expr` to make it work nicely inside a timed, 3. If `expr` is a `:(=)` expression with a curly assignment, wrap it in a `:const` to allow execution - see https://github.com/fonsp/Pluto.jl/issues/517 """ function preprocess_expr(expr::Expr) - if expr.head == :toplevel + if expr.head === :toplevel Expr(:block, expr.args...) - elseif expr.head == :module + elseif expr.head === :module Expr(:toplevel, expr) - elseif expr.head == :(=) && (expr.args[1] isa Expr && expr.args[1].head == :curly) + elseif expr.head === :(=) && (expr.args[1] isa Expr && expr.args[1].head == :curly) Expr(:const, expr) + elseif expr.head === :incomplete + Expr(:call, :(PlutoRunner.throw_syntax_error), expr.args...) else expr end diff --git a/src/analysis/is_just_text.jl b/src/analysis/is_just_text.jl index f4402a6b2a..38339f68af 100644 --- a/src/analysis/is_just_text.jl +++ b/src/analysis/is_just_text.jl @@ -1,12 +1,22 @@ -const md_and_friends = [Symbol("@md_str"), Symbol("@html_str"), :getindex] +const md_and_friends = [ + # Text + Symbol("@md_str"), + Symbol("@html_str"), + :getindex, +] """Does the cell only contain md"..." and html"..."? This is used to run these cells first.""" function is_just_text(topology::NotebookTopology, cell::Cell)::Bool # https://github.com/fonsp/Pluto.jl/issues/209 - isempty(topology.nodes[cell].definitions) && isempty(topology.nodes[cell].funcdefs_with_signatures) && - topology.nodes[cell].references βŠ† md_and_friends && + node = topology.nodes[cell] + ((isempty(node.definitions) && + isempty(node.funcdefs_with_signatures) && + node.references βŠ† md_and_friends) || + (length(node.references) == 2 && + :PlutoRunner in node.references && + Symbol("PlutoRunner.throw_syntax_error") in node.references)) && no_loops(ExpressionExplorer.maybe_macroexpand(topology.codes[cell].parsedcode; recursive=true)) end @@ -24,4 +34,4 @@ function no_loops(ex::Expr) end end -no_loops(x) = true \ No newline at end of file +no_loops(x) = true diff --git a/src/runner/PlutoRunner.jl b/src/runner/PlutoRunner.jl index 6916596b7a..bbd1800e00 100644 --- a/src/runner/PlutoRunner.jl +++ b/src/runner/PlutoRunner.jl @@ -273,7 +273,7 @@ function try_macroexpand(mod::Module, notebook_id::UUID, cell_id::UUID, expr; ca pop!(cell_expanded_exprs, cell_id, nothing) # Remove toplevel block, as that screws with the computer and everything - expr_not_toplevel = if expr.head == :toplevel || expr.head == :block + expr_not_toplevel = if Meta.isexpr(expr, (:toplevel, :block)) Expr(:block, expr.args...) else @warn "try_macroexpand expression not :toplevel or :block" expr @@ -1030,7 +1030,177 @@ format_output(::Nothing; context=default_iocontext) = ("", MIME"text/plain"()) "Downstream packages can set this to false to obtain unprettified stack traces." const PRETTY_STACKTRACES = Ref(true) +# @codemirror/lint has only three levels +function convert_julia_syntax_level(level) + level == :error ? "error" : + level == :warning ? "warning" : "info" +end + +""" + map_byte_range_to_utf16_codepoints(s::String, start_byte::Int, end_byte::Int)::Tuple{Int,Int} + +Taken from `Base.transcode(::Type{UInt16}, src::Vector{UInt8})` +but without line constraints. It also does not support invalid +UTF-8 encoding which `String` should never be anyway. + +This maps the given raw byte range `(start_byte, end_byte)` range to UTF-16 codepoints indices. + +The resulting range can then be used by code-mirror on the frontend, quoting from the code-mirror docs: + +> Character positions are counted from zero, and count each line break and UTF-16 code unit as one unit. + +Examples: +```julia + 123 + vv +julia> map_byte_range_to_utf16_codepoints("abc", 2, 3) +(2, 3) + + 1122 + v v +julia> map_byte_range_to_utf16_codepoints("πŸ•πŸ•", 1, 8) +(1, 4) + + 11233 + v v +julia> map_byte_range_to_utf16_codepoints("πŸ•cπŸ•", 1, 5) +(1, 3) +``` +""" +function map_byte_range_to_utf16_codepoints(s, start_byte, end_byte) + invalid_utf8() = error("invalid UTF-8 string") + codeunit(s) == UInt8 || invalid_utf8() + + i, n = 1, ncodeunits(s) + u16 = 0 + + from, to = -1, -1 + a = codeunit(s, 1) + while true + if i == start_byte + from = u16 + end + if i == end_byte + to = u16 + break + end + if i < n && -64 <= a % Int8 <= -12 # multi-byte character + i += 1 + b = codeunit(s, i) + if -64 <= (b % Int8) || a == 0xf4 && 0x8f < b + # invalid UTF-8 (non-continuation of too-high code point) + invalid_utf8() + elseif a < 0xe0 # 2-byte UTF-8 + if i == start_byte + from = u16 + end + if i == end_byte + to = u16 + break + end + elseif i < n # 3/4-byte character + i += 1 + c = codeunit(s, i) + if -64 <= (c % Int8) # invalid UTF-8 (non-continuation) + invalid_utf8() + elseif a < 0xf0 # 3-byte UTF-8 + if i == start_byte + from = u16 + end + if i == end_byte + to = u16 + break + end + elseif i < n + i += 1 + d = codeunit(s, i) + if -64 <= (d % Int8) # invalid UTF-8 (non-continuation) + invalid_utf8() + elseif a == 0xf0 && b < 0x90 # overlong encoding + invalid_utf8() + else # 4-byte UTF-8 && 2 codeunits UTF-16 + u16 += 1 + if i == start_byte + from = u16 + end + if i == end_byte + to = u16 + break + end + end + else # too short + invalid_utf8() + end + else # too short + invalid_utf8() + end + else + # ASCII or invalid UTF-8 (continuation byte or too-high code point) + end + u16 += 1 + if i >= n + break + end + i += 1 + a = codeunit(s, i) + end + + if from == -1 + from = u16 + end + if to == -1 + to = u16 + end + + return (from, to) +end + +function convert_diagnostic_to_dict(source, diag) + code = source.code + + # JuliaSyntax uses `last_byte < first_byte` to signal an empty range. + # https://github.com/JuliaLang/JuliaSyntax.jl/blob/97e2825c68e770a3f56f0ec247deda1a8588070c/src/diagnostics.jl#L67-L75 + # it references the byte range as such: `source[first_byte:last_byte]` whereas codemirror + # is non inclusive, therefore we move the `last_byte` to the next valid character in the string, + # an empty range then becomes `from == to`, also JuliaSyntax is one based whereas code-mirror is zero-based + # but this is handled in `map_byte_range_to_utf16_codepoints` with `u16 = 0` initially. + first_byte = min(diag.first_byte, lastindex(code) + 1) + last_byte = min(nextind(code, diag.last_byte), lastindex(code) + 1) + + from, to = map_byte_range_to_utf16_codepoints(code, first_byte, last_byte) + + Dict(:from => from, + :to => to, + :message => diag.message, + :source => "JuliaSyntax.jl", + :line => first(Base.JuliaSyntax.source_location(source, diag.first_byte)), + :severity => convert_julia_syntax_level(diag.level)) +end + +function convert_parse_error_to_dict(ex) + Dict( + :source => ex.source.code, + :diagnostics => [ + convert_diagnostic_to_dict(ex.source, diag) + for diag in ex.diagnostics + ] + ) +end + +function throw_syntax_error(@nospecialize(syntax_err)) + syntax_err isa String && (syntax_err = "syntax: $syntax_err") + syntax_err isa Exception || (syntax_err = ErrorException(syntax_err)) + throw(syntax_err) +end + +const has_julia_syntax = isdefined(Base, :JuliaSyntax) && fieldcount(Base.Meta.ParseError) == 2 + function format_output(val::CapturedException; context=default_iocontext) + if has_julia_syntax && val.ex isa Base.Meta.ParseError && val.ex.detail isa Base.JuliaSyntax.ParseError + dict = convert_parse_error_to_dict(val.ex.detail) + return dict, MIME"application/vnd.pluto.parseerror+object"() + end + stacktrace = if PRETTY_STACKTRACES[] ## We hide the part of the stacktrace that belongs to Pluto's evalling of user code. stack = [s for (s, _) in val.processed_bt] diff --git a/test/Dynamic.jl b/test/Dynamic.jl index 669017f640..56496f0d2d 100644 --- a/test/Dynamic.jl +++ b/test/Dynamic.jl @@ -156,7 +156,7 @@ end let doc_output = Pluto.PlutoRunner.doc_fetcher("sor", Main)[1] @test occursin("Similar results:", doc_output) - @test occursin("sortperm", doc_output) + @test occursin("sort", doc_output) end @test occursin("\\div", Pluto.PlutoRunner.doc_fetcher("Γ·", Main)[1]) diff --git a/test/ExpressionExplorer.jl b/test/ExpressionExplorer.jl index afe010c1ff..5ab13b83a9 100644 --- a/test/ExpressionExplorer.jl +++ b/test/ExpressionExplorer.jl @@ -1,4 +1,5 @@ using Test +import Pluto: PlutoRunner #= `@test_broken` means that the test doesn't pass right now, but we want it to pass. Feel free to try to fix it and open a PR! @@ -795,3 +796,16 @@ Some of these @test_broken lines are commented out to prevent printing to the te @test :Date ∈ rn.references end end + +@testset "UTF-8 to Codemirror UTF-16 byte mapping" begin + # range ends are non inclusives + tests = [ + (" aaaa", (2, 4), (1, 3)), # cm is zero based + (" πŸ•πŸ•", (2, 6), (1, 3)), # a πŸ• is two UTF16 codeunits + (" πŸ•πŸ•", (6, 10), (3, 5)), # a πŸ• is two UTF16 codeunits + ] + for (s, (start_byte, end_byte), (from, to)) in tests + @show s + @test PlutoRunner.map_byte_range_to_utf16_codepoints(s, start_byte, end_byte) == (from, to) + end +end diff --git a/test/React.jl b/test/React.jl index 191540c53a..0921a4ce0d 100644 --- a/test/React.jl +++ b/test/React.jl @@ -1752,4 +1752,28 @@ import Distributed update_run!(🍭, notebook, notebook.cells) @test all(noerror, notebook.cells) end + + @testset "ParseError messages" begin + notebook = Notebook(Cell.([ + "begin", + "\n\nend", + ])) + update_run!(🍭, notebook, notebook.cells) + @test Pluto.is_just_text(notebook.topology, notebook.cells[1]) + @test Pluto.is_just_text(notebook.topology, notebook.cells[2]) + @static if VERSION >= v"1.10.0-DEV.1548" # ~JuliaSyntax PR Pluto.jl#2526 julia#46372 + @test haskey(notebook.cells[1].output.body, :source) + @test haskey(notebook.cells[1].output.body, :diagnostics) + + @test haskey(notebook.cells[2].output.body, :source) + @test haskey(notebook.cells[2].output.body, :diagnostics) + else + @test !occursinerror("(incomplete ", notebook.cells[1]) + @test !occursinerror("(incomplete ", notebook.cells[2]) + + @show notebook.cells[1].output.body + @test startswith(notebook.cells[1].output.body[:msg], "syntax:") + @test startswith(notebook.cells[2].output.body[:msg], "syntax:") + end + end end diff --git a/test/packages/Basic.jl b/test/packages/Basic.jl index 773be1ec71..7cc83ee9b1 100644 --- a/test/packages/Basic.jl +++ b/test/packages/Basic.jl @@ -648,7 +648,25 @@ import Distributed WorkspaceManager.unmake_workspace((🍭, notebook)) end - + + @testset "PlutoRunner Syntax Error" begin + notebook = Notebook([ + Cell("1 +"), + Cell("PlutoRunner.throw_syntax_error"), + Cell("PlutoRunner.throw_syntax_error(1)"), + ]) + + update_run!(🍭, notebook, notebook.cells) + + @test notebook.cells[1].errored + @test noerror(notebook.cells[2]) + @test notebook.cells[3].errored + + @test Pluto.is_just_text(notebook.topology, notebook.cells[1]) + @test !Pluto.is_just_text(notebook.topology, notebook.cells[2]) # Not a syntax error form + @test Pluto.is_just_text(notebook.topology, notebook.cells[3]) + end + @testset "Precompilation" begin compilation_dir = joinpath(DEPOT_PATH[1], "compiled", "v$(VERSION.major).$(VERSION.minor)") @assert isdir(compilation_dir)