diff --git a/tests/spec/features/highlighting_mir_output_spec.rb b/tests/spec/features/highlighting_mir_output_spec.rb new file mode 100644 index 000000000..efa473d45 --- /dev/null +++ b/tests/spec/features/highlighting_mir_output_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' +require 'support/editor' +require 'support/playground_actions' + +RSpec.feature "Highlighting MIR output", type: :feature, js: true do + include PlaygroundActions + + before do + visit '/' + editor.set(code) + in_build_menu { click_on("MIR") } + end + + scenario "error locations are links" do + within('.output-mir') do + click_link('src/main.rs:4:14: 4:19', match: :first) + end + expect(editor).to have_highlighted_text('a + b') + end + + def editor + Editor.new(page) + end + + def code + <<~EOF + fn main() { + let a = 1; + let b = 2; + let _c = a + b; + } + EOF + end +end diff --git a/tests/spec/support/editor.rb b/tests/spec/support/editor.rb index a72695bd6..5cfb34277 100644 --- a/tests/spec/support/editor.rb +++ b/tests/spec/support/editor.rb @@ -15,4 +15,17 @@ def set(text) def has_line?(text) page.has_css? '.ace_line', text: text end + + def has_highlighted_text?(text) + page.within('.editor .ace_text-input', visible: :any) do + selected = page.evaluate_script <<~JS + (() => { + const editor = document.querySelector('.ace_editor').env.editor; + return editor.getSelectedText(); + })() + JS + + selected == text + end + end end diff --git a/ui/frontend/AdvancedEditor.tsx b/ui/frontend/AdvancedEditor.tsx index 8fc56bc37..35f27b4a9 100644 --- a/ui/frontend/AdvancedEditor.tsx +++ b/ui/frontend/AdvancedEditor.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { connect } from 'react-redux'; import State from './state'; -import { CommonEditorProps, Crate, Edition, Focus, PairCharacters } from './types'; +import { CommonEditorProps, Crate, Edition, Focus, PairCharacters, Position, Selection } from './types'; type Ace = typeof import('ace-builds'); type AceEditor = import('ace-builds').Ace.Editor; @@ -63,10 +63,8 @@ interface AdvancedEditorProps { execute: () => any; keybinding?: string; onEditCode: (_: string) => any; - position: { - line: number; - column: number; - }; + position: Position; + selection: Selection; theme: string; crates: Crate[]; focus?: Focus; @@ -253,6 +251,28 @@ const AdvancedEditor: React.SFC = props => { editor.focus(); }, [])); + const selectionProps = useMemo(() => ({ + selection: props.selection, + ace: props.ace, + }), [props.selection, props.ace]); + + useEditorProp(editor, selectionProps, useCallback((editor, { ace, selection }) => { + if (selection.start && selection.end) { + // Columns are zero-indexed in ACE, but why does the selection + // API and `gotoLine` treat the row/line differently? + const toPoint = ({ line, column }: Position) => ({ row: line - 1, column: column - 1 }); + + const start = toPoint(selection.start); + const end = toPoint(selection.end); + + const range = new ace.Range(start.row, start.column, end.row, end.column); + + editor.selection.setRange(range); + editor.renderer.scrollCursorIntoView(start); + editor.focus(); + } + }, [])); + // There's a tricky bug with Ace: // // 1. Open the page @@ -297,10 +317,8 @@ interface AdvancedEditorAsyncProps { execute: () => any; keybinding?: string; onEditCode: (_: string) => any; - position: { - line: number; - column: number; - }; + position: Position; + selection: Selection; theme: string; crates: Crate[]; focus?: Focus; diff --git a/ui/frontend/Editor.tsx b/ui/frontend/Editor.tsx index 1bae9f90d..400410dac 100644 --- a/ui/frontend/Editor.tsx +++ b/ui/frontend/Editor.tsx @@ -3,9 +3,49 @@ import { useSelector, useDispatch } from 'react-redux'; import * as actions from './actions'; import AdvancedEditor from './AdvancedEditor'; -import { CommonEditorProps, Editor as EditorType } from './types'; +import { CommonEditorProps, Editor as EditorType, Position, Selection } from './types'; import { State } from './reducers'; +class CodeByteOffsets { + readonly code: string; + readonly lines: string[]; + + constructor(code: string) { + this.code = code; + this.lines = code.split('\n'); + } + + public lineToOffsets(line: number) { + const precedingBytes = this.bytesBeforeLine(line); + + const highlightedLine = this.lines[line]; + const highlightedBytes = highlightedLine.length; + + return [precedingBytes, precedingBytes + highlightedBytes]; + } + + public rangeToOffsets(start: Position, end: Position) { + const startBytes = this.positionToBytes(start); + const endBytes = this.positionToBytes(end); + return [startBytes, endBytes]; + } + + private positionToBytes(position: Position) { + // Subtract one as this logic is zero-based and the columns are one-based + return this.bytesBeforeLine(position.line) + position.column - 1; + } + + private bytesBeforeLine(line: number) { + // Subtract one as this logic is zero-based and the lines are one-based + line -= 1; + + const precedingLines = this.lines.slice(0, line); + + // Add one to account for the newline we split on and removed + return precedingLines.map(l => l.length + 1).reduce((a, b) => a + b); + } +} + class SimpleEditor extends React.PureComponent { private _editor: HTMLTextAreaElement; @@ -35,28 +75,33 @@ class SimpleEditor extends React.PureComponent { public componentDidUpdate(prevProps, _prevState) { this.gotoPosition(prevProps.position, this.props.position); + this.setSelection(prevProps.selection, this.props.selection); } - private gotoPosition(oldPosition, newPosition) { + private gotoPosition(oldPosition: Position, newPosition: Position) { const editor = this._editor; if (!newPosition || !editor) { return; } if (newPosition === oldPosition) { return; } - // Subtract one as this logix is zero-based and the lines are one-based - const line = newPosition.line - 1; - const { code } = this.props; + const offsets = new CodeByteOffsets(this.props.code); + const [startBytes, endBytes] = offsets.lineToOffsets(newPosition.line); - const lines = code.split('\n'); + editor.focus(); + editor.setSelectionRange(startBytes, endBytes); + } - const precedingLines = lines.slice(0, line); - const highlightedLine = lines[line]; + private setSelection(oldSelection: Selection, newSelection: Selection) { + const editor = this._editor; - // Add one to account for the newline we split on and removed - const precedingBytes = precedingLines.map(l => l.length + 1).reduce((a, b) => a + b); - const highlightedBytes = highlightedLine.length; + if (!newSelection || !editor) { return; } + if (newSelection === oldSelection) { return; } + + const offsets = new CodeByteOffsets(this.props.code); + const [startBytes, endBytes] = offsets.rangeToOffsets(newSelection.start, newSelection.end); - editor.setSelectionRange(precedingBytes, precedingBytes + highlightedBytes); + editor.focus(); + editor.setSelectionRange(startBytes, endBytes); } } @@ -64,6 +109,7 @@ const Editor: React.SFC = () => { const code = useSelector((state: State) => state.code); const editor = useSelector((state: State) => state.configuration.editor); const position = useSelector((state: State) => state.position); + const selection = useSelector((state: State) => state.selection); const crates = useSelector((state: State) => state.crates); const dispatch = useDispatch(); @@ -76,6 +122,7 @@ const Editor: React.SFC = () => {
diff --git a/ui/frontend/Output.tsx b/ui/frontend/Output.tsx index 4e046c543..64d302ff5 100644 --- a/ui/frontend/Output.tsx +++ b/ui/frontend/Output.tsx @@ -9,6 +9,7 @@ import Execute from './Output/Execute'; import Gist from './Output/Gist'; import Section from './Output/Section'; import SimplePane, { SimplePaneProps } from './Output/SimplePane'; +import PaneWithMir from './Output/PaneWithMir'; import * as selectors from './selectors'; const Tab: React.SFC = ({ kind, focus, label, onClick, tabProps }) => { @@ -82,7 +83,7 @@ const Output: React.SFC = () => { {focus === Focus.MacroExpansion && } {focus === Focus.Asm && } {focus === Focus.LlvmIr && } - {focus === Focus.Mir && } + {focus === Focus.Mir && } {focus === Focus.Wasm && } {focus === Focus.Gist && }
diff --git a/ui/frontend/Output/PaneWithMir.tsx b/ui/frontend/Output/PaneWithMir.tsx new file mode 100644 index 000000000..1a597d06d --- /dev/null +++ b/ui/frontend/Output/PaneWithMir.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { PrismCode } from 'react-prism'; + +import Header from './Header'; +import SimplePane, { SimplePaneProps } from './SimplePane'; + +interface PaneWithMirProps extends SimplePaneProps { + code?: string; +} + +const PaneWithMir: React.SFC = ({ code, ...rest }) => ( + +
+
+
+        
+          {code}
+        
+      
+
+
+); + +export default PaneWithMir; diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index f6ae70a5d..55c59ff4c 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -27,6 +27,8 @@ import { PrimaryActionAuto, PrimaryActionCore, ProcessAssembly, + Position, + makePosition, } from './types'; const routes = { @@ -92,6 +94,7 @@ export enum ActionType { AddImport = 'ADD_IMPORT', EnableFeatureGate = 'ENABLE_FEATURE_GATE', GotoPosition = 'GOTO_POSITION', + SelectText = 'SELECT_TEXT', RequestFormat = 'REQUEST_FORMAT', FormatSucceeded = 'FORMAT_SUCCEEDED', FormatFailed = 'FORMAT_FAILED', @@ -429,8 +432,11 @@ export const addImport = (code: string) => export const enableFeatureGate = (featureGate: string) => createAction(ActionType.EnableFeatureGate, { featureGate }); -export const gotoPosition = (line, column) => - createAction(ActionType.GotoPosition, { line: +line, column: +column }); +export const gotoPosition = (line: string | number, column: string | number) => + createAction(ActionType.GotoPosition, makePosition(line, column)); + +export const selectText = (start: Position, end: Position) => + createAction(ActionType.SelectText, { start, end }); const requestFormat = () => createAction(ActionType.RequestFormat); @@ -792,6 +798,7 @@ export type Action = | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType diff --git a/ui/frontend/highlighting.ts b/ui/frontend/highlighting.ts index 6bd667efc..31934f5cb 100644 --- a/ui/frontend/highlighting.ts +++ b/ui/frontend/highlighting.ts @@ -1,9 +1,11 @@ import Prism from 'prismjs'; +import { makePosition } from './types'; export function configureRustErrors({ enableFeatureGate, getChannel, gotoPosition, + selectText, addImport, reExecuteWithBacktrace, }) { @@ -48,6 +50,10 @@ export function configureRustErrors({ 'backtrace-enable': /Run with `RUST_BACKTRACE=1` environment variable to display a backtrace/i, }; + Prism.languages.rust_mir = { // eslint-disable-line @typescript-eslint/camelcase + 'mir-source': /src\/.*.rs:\d+:\d+: \d+:\d+/, + } + Prism.hooks.add('wrap', env => { if (env.type === 'error-explanation') { const errorMatch = /E\d+/.exec(env.content); @@ -104,6 +110,16 @@ export function configureRustErrors({ env.attributes['data-line'] = line; env.attributes['data-col'] = '1'; } + if (env.type === 'mir-source') { + const lineMatch = /(\d+):(\d+): (\d+):(\d+)/.exec(env.content); + const [_, startLine, startCol, endLine, endCol] = lineMatch; + env.tag = 'a'; + env.attributes.href = '#'; + env.attributes['data-start-line'] = startLine; + env.attributes['data-start-col'] = startCol; + env.attributes['data-end-line'] = endLine; + env.attributes['data-end-col'] = endCol; + } }); Prism.hooks.add('after-highlight', env => { @@ -141,5 +157,17 @@ export function configureRustErrors({ reExecuteWithBacktrace(); }; }); + + const mirSourceLinks = env.element.querySelectorAll('.mir-source'); + Array.from(mirSourceLinks).forEach((link: HTMLAnchorElement) => { + const { startLine, startCol, endLine, endCol } = link.dataset; + const start = makePosition(startLine, startCol); + const end = makePosition(endLine, endCol); + + link.onclick = e => { + e.preventDefault(); + selectText(start, end); + }; + }); }); } diff --git a/ui/frontend/index.tsx b/ui/frontend/index.tsx index d891db9ab..1d015dd99 100644 --- a/ui/frontend/index.tsx +++ b/ui/frontend/index.tsx @@ -14,6 +14,7 @@ import { editCode, enableFeatureGate, gotoPosition, + selectText, addImport, performCratesLoad, performVersionsLoad, @@ -49,6 +50,7 @@ const store = createStore(playgroundApp, initialState, enhancers); configureRustErrors({ enableFeatureGate: featureGate => store.dispatch(enableFeatureGate(featureGate)), gotoPosition: (line, col) => store.dispatch(gotoPosition(line, col)), + selectText: (start, end) => store.dispatch(selectText(start, end)), addImport: (code) => store.dispatch(addImport(code)), reExecuteWithBacktrace: () => store.dispatch(reExecuteWithBacktrace()), getChannel: () => store.getState().configuration.channel, diff --git a/ui/frontend/reducers/index.ts b/ui/frontend/reducers/index.ts index 4f89407e7..32d3224ab 100644 --- a/ui/frontend/reducers/index.ts +++ b/ui/frontend/reducers/index.ts @@ -8,6 +8,7 @@ import notifications from './notifications'; import output from './output'; import page from './page'; import position from './position'; +import selection from './selection'; import versions from './versions'; const playgroundApp = combineReducers({ @@ -19,6 +20,7 @@ const playgroundApp = combineReducers({ output, page, position, + selection, versions, }); diff --git a/ui/frontend/reducers/selection.ts b/ui/frontend/reducers/selection.ts new file mode 100644 index 000000000..57ce86895 --- /dev/null +++ b/ui/frontend/reducers/selection.ts @@ -0,0 +1,18 @@ +import { Action, ActionType } from '../actions'; +import { Selection } from '../types'; + +const DEFAULT: Selection = { + start: null, + end: null, +}; + +export default function position(state = DEFAULT, action: Action) { + switch (action.type) { + case ActionType.SelectText: { + const { start, end } = action; + return { ...state, start, end }; + } + default: + return state; + } +} diff --git a/ui/frontend/types.ts b/ui/frontend/types.ts index bce7d70fc..2691602df 100644 --- a/ui/frontend/types.ts +++ b/ui/frontend/types.ts @@ -5,6 +5,14 @@ export interface Position { column: number; } +export const makePosition = (line: string | number, column: string | number): Position => + ({ line: +line, column: +column }); + +export interface Selection { + start?: Position; + end?: Position; +} + export interface Crate { id: string; name: string; @@ -22,6 +30,7 @@ export interface CommonEditorProps { execute: () => any; onEditCode: (_: string) => any; position: Position; + selection: Selection; crates: Crate[]; }