Skip to content

Commit 9c8b31a

Browse files
authored
Merge pull request #605 from integer32llc/mir-link
2 parents ab9c098 + 9804256 commit 9c8b31a

File tree

12 files changed

+227
-24
lines changed

12 files changed

+227
-24
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
require 'spec_helper'
2+
require 'support/editor'
3+
require 'support/playground_actions'
4+
5+
RSpec.feature "Highlighting MIR output", type: :feature, js: true do
6+
include PlaygroundActions
7+
8+
before do
9+
visit '/'
10+
editor.set(code)
11+
in_build_menu { click_on("MIR") }
12+
end
13+
14+
scenario "error locations are links" do
15+
within('.output-mir') do
16+
click_link('src/main.rs:4:14: 4:19', match: :first)
17+
end
18+
expect(editor).to have_highlighted_text('a + b')
19+
end
20+
21+
def editor
22+
Editor.new(page)
23+
end
24+
25+
def code
26+
<<~EOF
27+
fn main() {
28+
let a = 1;
29+
let b = 2;
30+
let _c = a + b;
31+
}
32+
EOF
33+
end
34+
end

tests/spec/support/editor.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,17 @@ def set(text)
1515
def has_line?(text)
1616
page.has_css? '.ace_line', text: text
1717
end
18+
19+
def has_highlighted_text?(text)
20+
page.within('.editor .ace_text-input', visible: :any) do
21+
selected = page.evaluate_script <<~JS
22+
(() => {
23+
const editor = document.querySelector('.ace_editor').env.editor;
24+
return editor.getSelectedText();
25+
})()
26+
JS
27+
28+
selected == text
29+
end
30+
end
1831
end

ui/frontend/AdvancedEditor.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import { connect } from 'react-redux';
33

44
import State from './state';
5-
import { CommonEditorProps, Crate, Edition, Focus, PairCharacters } from './types';
5+
import { CommonEditorProps, Crate, Edition, Focus, PairCharacters, Position, Selection } from './types';
66

77
type Ace = typeof import('ace-builds');
88
type AceEditor = import('ace-builds').Ace.Editor;
@@ -63,10 +63,8 @@ interface AdvancedEditorProps {
6363
execute: () => any;
6464
keybinding?: string;
6565
onEditCode: (_: string) => any;
66-
position: {
67-
line: number;
68-
column: number;
69-
};
66+
position: Position;
67+
selection: Selection;
7068
theme: string;
7169
crates: Crate[];
7270
focus?: Focus;
@@ -253,6 +251,28 @@ const AdvancedEditor: React.SFC<AdvancedEditorProps> = props => {
253251
editor.focus();
254252
}, []));
255253

254+
const selectionProps = useMemo(() => ({
255+
selection: props.selection,
256+
ace: props.ace,
257+
}), [props.selection, props.ace]);
258+
259+
useEditorProp(editor, selectionProps, useCallback((editor, { ace, selection }) => {
260+
if (selection.start && selection.end) {
261+
// Columns are zero-indexed in ACE, but why does the selection
262+
// API and `gotoLine` treat the row/line differently?
263+
const toPoint = ({ line, column }: Position) => ({ row: line - 1, column: column - 1 });
264+
265+
const start = toPoint(selection.start);
266+
const end = toPoint(selection.end);
267+
268+
const range = new ace.Range(start.row, start.column, end.row, end.column);
269+
270+
editor.selection.setRange(range);
271+
editor.renderer.scrollCursorIntoView(start);
272+
editor.focus();
273+
}
274+
}, []));
275+
256276
// There's a tricky bug with Ace:
257277
//
258278
// 1. Open the page
@@ -297,10 +317,8 @@ interface AdvancedEditorAsyncProps {
297317
execute: () => any;
298318
keybinding?: string;
299319
onEditCode: (_: string) => any;
300-
position: {
301-
line: number;
302-
column: number;
303-
};
320+
position: Position;
321+
selection: Selection;
304322
theme: string;
305323
crates: Crate[];
306324
focus?: Focus;

ui/frontend/Editor.tsx

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,49 @@ import { useSelector, useDispatch } from 'react-redux';
33

44
import * as actions from './actions';
55
import AdvancedEditor from './AdvancedEditor';
6-
import { CommonEditorProps, Editor as EditorType } from './types';
6+
import { CommonEditorProps, Editor as EditorType, Position, Selection } from './types';
77
import { State } from './reducers';
88

9+
class CodeByteOffsets {
10+
readonly code: string;
11+
readonly lines: string[];
12+
13+
constructor(code: string) {
14+
this.code = code;
15+
this.lines = code.split('\n');
16+
}
17+
18+
public lineToOffsets(line: number) {
19+
const precedingBytes = this.bytesBeforeLine(line);
20+
21+
const highlightedLine = this.lines[line];
22+
const highlightedBytes = highlightedLine.length;
23+
24+
return [precedingBytes, precedingBytes + highlightedBytes];
25+
}
26+
27+
public rangeToOffsets(start: Position, end: Position) {
28+
const startBytes = this.positionToBytes(start);
29+
const endBytes = this.positionToBytes(end);
30+
return [startBytes, endBytes];
31+
}
32+
33+
private positionToBytes(position: Position) {
34+
// Subtract one as this logic is zero-based and the columns are one-based
35+
return this.bytesBeforeLine(position.line) + position.column - 1;
36+
}
37+
38+
private bytesBeforeLine(line: number) {
39+
// Subtract one as this logic is zero-based and the lines are one-based
40+
line -= 1;
41+
42+
const precedingLines = this.lines.slice(0, line);
43+
44+
// Add one to account for the newline we split on and removed
45+
return precedingLines.map(l => l.length + 1).reduce((a, b) => a + b);
46+
}
47+
}
48+
949
class SimpleEditor extends React.PureComponent<CommonEditorProps> {
1050
private _editor: HTMLTextAreaElement;
1151

@@ -35,35 +75,41 @@ class SimpleEditor extends React.PureComponent<CommonEditorProps> {
3575

3676
public componentDidUpdate(prevProps, _prevState) {
3777
this.gotoPosition(prevProps.position, this.props.position);
78+
this.setSelection(prevProps.selection, this.props.selection);
3879
}
3980

40-
private gotoPosition(oldPosition, newPosition) {
81+
private gotoPosition(oldPosition: Position, newPosition: Position) {
4182
const editor = this._editor;
4283

4384
if (!newPosition || !editor) { return; }
4485
if (newPosition === oldPosition) { return; }
4586

46-
// Subtract one as this logix is zero-based and the lines are one-based
47-
const line = newPosition.line - 1;
48-
const { code } = this.props;
87+
const offsets = new CodeByteOffsets(this.props.code);
88+
const [startBytes, endBytes] = offsets.lineToOffsets(newPosition.line);
4989

50-
const lines = code.split('\n');
90+
editor.focus();
91+
editor.setSelectionRange(startBytes, endBytes);
92+
}
5193

52-
const precedingLines = lines.slice(0, line);
53-
const highlightedLine = lines[line];
94+
private setSelection(oldSelection: Selection, newSelection: Selection) {
95+
const editor = this._editor;
5496

55-
// Add one to account for the newline we split on and removed
56-
const precedingBytes = precedingLines.map(l => l.length + 1).reduce((a, b) => a + b);
57-
const highlightedBytes = highlightedLine.length;
97+
if (!newSelection || !editor) { return; }
98+
if (newSelection === oldSelection) { return; }
99+
100+
const offsets = new CodeByteOffsets(this.props.code);
101+
const [startBytes, endBytes] = offsets.rangeToOffsets(newSelection.start, newSelection.end);
58102

59-
editor.setSelectionRange(precedingBytes, precedingBytes + highlightedBytes);
103+
editor.focus();
104+
editor.setSelectionRange(startBytes, endBytes);
60105
}
61106
}
62107

63108
const Editor: React.SFC = () => {
64109
const code = useSelector((state: State) => state.code);
65110
const editor = useSelector((state: State) => state.configuration.editor);
66111
const position = useSelector((state: State) => state.position);
112+
const selection = useSelector((state: State) => state.selection);
67113
const crates = useSelector((state: State) => state.crates);
68114

69115
const dispatch = useDispatch();
@@ -76,6 +122,7 @@ const Editor: React.SFC = () => {
76122
<div className="editor">
77123
<SelectedEditor code={code}
78124
position={position}
125+
selection={selection}
79126
crates={crates}
80127
onEditCode={onEditCode}
81128
execute={execute} />

ui/frontend/Output.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Execute from './Output/Execute';
99
import Gist from './Output/Gist';
1010
import Section from './Output/Section';
1111
import SimplePane, { SimplePaneProps } from './Output/SimplePane';
12+
import PaneWithMir from './Output/PaneWithMir';
1213
import * as selectors from './selectors';
1314

1415
const Tab: React.SFC<TabProps> = ({ kind, focus, label, onClick, tabProps }) => {
@@ -82,7 +83,7 @@ const Output: React.SFC = () => {
8283
{focus === Focus.MacroExpansion && <SimplePane {...macroExpansion} kind="macro-expansion" />}
8384
{focus === Focus.Asm && <PaneWithCode {...assembly} kind="asm" />}
8485
{focus === Focus.LlvmIr && <PaneWithCode {...llvmIr} kind="llvm-ir" />}
85-
{focus === Focus.Mir && <PaneWithCode {...mir} kind="mir" />}
86+
{focus === Focus.Mir && <PaneWithMir {...mir} kind="mir" />}
8687
{focus === Focus.Wasm && <PaneWithCode {...wasm} kind="wasm" />}
8788
{focus === Focus.Gist && <Gist />}
8889
</div>

ui/frontend/Output/PaneWithMir.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
import { PrismCode } from 'react-prism';
3+
4+
import Header from './Header';
5+
import SimplePane, { SimplePaneProps } from './SimplePane';
6+
7+
interface PaneWithMirProps extends SimplePaneProps {
8+
code?: string;
9+
}
10+
11+
const PaneWithMir: React.SFC<PaneWithMirProps> = ({ code, ...rest }) => (
12+
<SimplePane {...rest}>
13+
<div className="output-result">
14+
<Header label="Result" />
15+
<pre>
16+
<PrismCode className="language-rust_mir">
17+
{code}
18+
</PrismCode>
19+
</pre>
20+
</div>
21+
</SimplePane>
22+
);
23+
24+
export default PaneWithMir;

ui/frontend/actions.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
PrimaryActionAuto,
2828
PrimaryActionCore,
2929
ProcessAssembly,
30+
Position,
31+
makePosition,
3032
} from './types';
3133

3234
const routes = {
@@ -92,6 +94,7 @@ export enum ActionType {
9294
AddImport = 'ADD_IMPORT',
9395
EnableFeatureGate = 'ENABLE_FEATURE_GATE',
9496
GotoPosition = 'GOTO_POSITION',
97+
SelectText = 'SELECT_TEXT',
9598
RequestFormat = 'REQUEST_FORMAT',
9699
FormatSucceeded = 'FORMAT_SUCCEEDED',
97100
FormatFailed = 'FORMAT_FAILED',
@@ -429,8 +432,11 @@ export const addImport = (code: string) =>
429432
export const enableFeatureGate = (featureGate: string) =>
430433
createAction(ActionType.EnableFeatureGate, { featureGate });
431434

432-
export const gotoPosition = (line, column) =>
433-
createAction(ActionType.GotoPosition, { line: +line, column: +column });
435+
export const gotoPosition = (line: string | number, column: string | number) =>
436+
createAction(ActionType.GotoPosition, makePosition(line, column));
437+
438+
export const selectText = (start: Position, end: Position) =>
439+
createAction(ActionType.SelectText, { start, end });
434440

435441
const requestFormat = () =>
436442
createAction(ActionType.RequestFormat);
@@ -792,6 +798,7 @@ export type Action =
792798
| ReturnType<typeof addImport>
793799
| ReturnType<typeof enableFeatureGate>
794800
| ReturnType<typeof gotoPosition>
801+
| ReturnType<typeof selectText>
795802
| ReturnType<typeof requestFormat>
796803
| ReturnType<typeof receiveFormatSuccess>
797804
| ReturnType<typeof receiveFormatFailure>

ui/frontend/highlighting.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Prism from 'prismjs';
2+
import { makePosition } from './types';
23

34
export function configureRustErrors({
45
enableFeatureGate,
56
getChannel,
67
gotoPosition,
8+
selectText,
79
addImport,
810
reExecuteWithBacktrace,
911
}) {
@@ -48,6 +50,10 @@ export function configureRustErrors({
4850
'backtrace-enable': /Run with `RUST_BACKTRACE=1` environment variable to display a backtrace/i,
4951
};
5052

53+
Prism.languages.rust_mir = { // eslint-disable-line @typescript-eslint/camelcase
54+
'mir-source': /src\/.*.rs:\d+:\d+: \d+:\d+/,
55+
}
56+
5157
Prism.hooks.add('wrap', env => {
5258
if (env.type === 'error-explanation') {
5359
const errorMatch = /E\d+/.exec(env.content);
@@ -104,6 +110,16 @@ export function configureRustErrors({
104110
env.attributes['data-line'] = line;
105111
env.attributes['data-col'] = '1';
106112
}
113+
if (env.type === 'mir-source') {
114+
const lineMatch = /(\d+):(\d+): (\d+):(\d+)/.exec(env.content);
115+
const [_, startLine, startCol, endLine, endCol] = lineMatch;
116+
env.tag = 'a';
117+
env.attributes.href = '#';
118+
env.attributes['data-start-line'] = startLine;
119+
env.attributes['data-start-col'] = startCol;
120+
env.attributes['data-end-line'] = endLine;
121+
env.attributes['data-end-col'] = endCol;
122+
}
107123
});
108124

109125
Prism.hooks.add('after-highlight', env => {
@@ -141,5 +157,17 @@ export function configureRustErrors({
141157
reExecuteWithBacktrace();
142158
};
143159
});
160+
161+
const mirSourceLinks = env.element.querySelectorAll('.mir-source');
162+
Array.from(mirSourceLinks).forEach((link: HTMLAnchorElement) => {
163+
const { startLine, startCol, endLine, endCol } = link.dataset;
164+
const start = makePosition(startLine, startCol);
165+
const end = makePosition(endLine, endCol);
166+
167+
link.onclick = e => {
168+
e.preventDefault();
169+
selectText(start, end);
170+
};
171+
});
144172
});
145173
}

ui/frontend/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
editCode,
1515
enableFeatureGate,
1616
gotoPosition,
17+
selectText,
1718
addImport,
1819
performCratesLoad,
1920
performVersionsLoad,
@@ -49,6 +50,7 @@ const store = createStore(playgroundApp, initialState, enhancers);
4950
configureRustErrors({
5051
enableFeatureGate: featureGate => store.dispatch(enableFeatureGate(featureGate)),
5152
gotoPosition: (line, col) => store.dispatch(gotoPosition(line, col)),
53+
selectText: (start, end) => store.dispatch(selectText(start, end)),
5254
addImport: (code) => store.dispatch(addImport(code)),
5355
reExecuteWithBacktrace: () => store.dispatch(reExecuteWithBacktrace()),
5456
getChannel: () => store.getState().configuration.channel,

ui/frontend/reducers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import notifications from './notifications';
88
import output from './output';
99
import page from './page';
1010
import position from './position';
11+
import selection from './selection';
1112
import versions from './versions';
1213

1314
const playgroundApp = combineReducers({
@@ -19,6 +20,7 @@ const playgroundApp = combineReducers({
1920
output,
2021
page,
2122
position,
23+
selection,
2224
versions,
2325
});
2426

0 commit comments

Comments
 (0)