Skip to content

Commit d9a6985

Browse files
committed
feat: add actionable diagnostics
1 parent f0fd079 commit d9a6985

File tree

9 files changed

+242
-36
lines changed

9 files changed

+242
-36
lines changed

api/src/main/scala/org/scastie/api/CompilerInfo.scala

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,70 @@ object Severity {
99
}
1010

1111
sealed trait Severity
12-
case object Info extends Severity
12+
case object Info extends Severity
1313
case object Warning extends Severity
14-
case object Error extends Severity
14+
case object Error extends Severity
15+
16+
case class DiagnosticPosition(
17+
line: Int,
18+
character: Int
19+
)
20+
21+
object DiagnosticPosition {
22+
implicit val positionEncoder: Encoder[DiagnosticPosition] = deriveEncoder[DiagnosticPosition]
23+
implicit val positionDecoder: Decoder[DiagnosticPosition] = deriveDecoder[DiagnosticPosition]
24+
}
25+
26+
case class DiagnosticRange(
27+
start: DiagnosticPosition,
28+
end: DiagnosticPosition
29+
)
30+
31+
object DiagnosticRange {
32+
implicit val rangeEncoder: Encoder[DiagnosticRange] = deriveEncoder[DiagnosticRange]
33+
implicit val rangeDecoder: Decoder[DiagnosticRange] = deriveDecoder[DiagnosticRange]
34+
}
35+
36+
case class ScalaTextEdit(
37+
range: DiagnosticRange,
38+
newText: String
39+
)
40+
41+
object ScalaTextEdit {
42+
implicit val scalaTextEditEncoder: Encoder[ScalaTextEdit] = deriveEncoder[ScalaTextEdit]
43+
implicit val scalaTextEditDecoder: Decoder[ScalaTextEdit] = deriveDecoder[ScalaTextEdit]
44+
}
45+
46+
case class ScalaWorkspaceEdit(
47+
changes: List[ScalaTextEdit]
48+
)
49+
50+
object ScalaWorkspaceEdit {
51+
implicit val scalaWorkspaceEditEncoder: Encoder[ScalaWorkspaceEdit] = deriveEncoder[ScalaWorkspaceEdit]
52+
implicit val scalaWorkspaceEditDecoder: Decoder[ScalaWorkspaceEdit] = deriveDecoder[ScalaWorkspaceEdit]
53+
}
54+
55+
case class ScalaAction(
56+
title: String,
57+
description: Option[String],
58+
edit: Option[ScalaWorkspaceEdit]
59+
)
60+
61+
object ScalaAction {
62+
implicit val scalaActionEncoder: Encoder[ScalaAction] = deriveEncoder[ScalaAction]
63+
implicit val scalaActionDecoder: Decoder[ScalaAction] = deriveDecoder[ScalaAction]
64+
}
1565

1666
object Problem {
1767
implicit val problemEncoder: Encoder[Problem] = deriveEncoder[Problem]
1868
implicit val problemDecoder: Decoder[Problem] = deriveDecoder[Problem]
1969
}
2070

21-
case class Problem(severity: Severity, line: Option[Int], startColumn: Option[Int], endColumn: Option[Int], message: String)
71+
case class Problem(
72+
severity: Severity,
73+
line: Option[Int],
74+
startColumn: Option[Int],
75+
endColumn: Option[Int],
76+
message: String,
77+
actions: Option[List[ScalaAction]] = None
78+
)

client/src/main/resources/sass/codemirror.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@
101101
}
102102
}
103103

104+
.cm-actions {
105+
cursor: pointer;
106+
margin-left: 0px;
107+
padding: 0px;
108+
109+
color: #5fa8dd;
110+
background-color: transparent;
111+
112+
&:hover {
113+
color: #93c5fd;
114+
text-decoration: underline;
115+
}
116+
}
117+
104118
.cm-hints.presentation-mode {
105119
margin-left: 0;
106120
margin-top: -$editor-topbar-height;

client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,25 +153,70 @@ object CodeEditor {
153153
}
154154

155155
Diagnostic(startColumn, problem.message, parseSeverity(problem.severity), endColumn)
156-
.setRenderMessage(CallbackTo {
156+
.setRenderMessage((_: EditorView) => {
157157
val wrapper = dom.document.createElement("pre")
158158
wrapper.innerHTML = HTMLFormatter.format(problem.message)
159159
wrapper
160160
})
161161
}
162162

163+
/* e.g.: line: 5, character: 10 -> offset: 67 */
164+
private def positionToOffset(line: Int, character: Int, doc: Text): Int = {
165+
val clampedLine = ((line + 1) min doc.lines.toInt) max 1
166+
val lineInfo = doc.line(clampedLine)
167+
(lineInfo.from.toInt + character).min(doc.length.toInt)
168+
}
169+
170+
def problemToActions(problem: Problem, doc: Text): Option[List[Action]] = {
171+
problem.actions.map { scalaActions =>
172+
scalaActions.map { scalaAction =>
173+
Action(
174+
apply = (view: EditorView, _: Double, _: Double) => {
175+
scalaAction.edit.foreach { workspaceEdit =>
176+
val changes = workspaceEdit.changes.map { textEdit =>
177+
val from = positionToOffset(textEdit.range.start.line, textEdit.range.start.character, doc)
178+
val to = positionToOffset(textEdit.range.end.line, textEdit.range.end.character, doc)
179+
180+
js.Dynamic.literal(
181+
"from" -> from,
182+
"to" -> to,
183+
"insert" -> textEdit.newText
184+
)
185+
}
186+
187+
view.dispatch(js.Dynamic.literal(
188+
"changes" -> js.Array(changes: _*)
189+
).asInstanceOf[TransactionSpec])
190+
}
191+
192+
Callback.empty
193+
},
194+
name = scalaAction.title
195+
).setMarkClass("cm-actions")
196+
}
197+
}
198+
}
199+
163200
private def getDecorations(props: CodeEditor, doc: Text): js.Array[Diagnostic] = {
164201
val errors = props.compilationInfos
165202
.filter(prob => prob.line.isDefined)
166-
.map(problemToDiagnostic(_, doc))
203+
.map { prob =>
204+
val diagnostic = problemToDiagnostic(prob, doc)
205+
206+
problemToActions(prob, doc).foreach { actions =>
207+
diagnostic.setActions(actions.toJSArray)
208+
}
209+
210+
diagnostic
211+
}
167212

168213
val runtimeErrors = props.runtimeError.map(runtimeError => {
169214
val line = runtimeError.line.getOrElse(1).min(doc.lines.toInt)
170215
val lineInfo = doc.line(line)
171216
val msg = if (runtimeError.fullStack.nonEmpty) runtimeError.fullStack else runtimeError.message
172217

173218
Diagnostic(lineInfo.from, msg, codemirrorLintStrings.error, lineInfo.to)
174-
.setRenderMessage(CallbackTo {
219+
.setRenderMessage((_: EditorView) => {
175220
val wrapper = dom.document.createElement("pre")
176221
wrapper.innerHTML = HTMLFormatter.format(msg)
177222
wrapper

client/src/main/scala/org/scastie/client/components/editor/MetalsDiagnostics.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ trait MetalsDiagnostics extends MetalsClient with DebouncingCapabilities {
4141
makeRequest(toLSPRequest(code, 0), "diagnostics").map { maybeText =>
4242
if (myGeneration == MetalsDiagnostics.currentGeneration) {
4343
parseMetalsResponse[Set[api.Problem]](maybeText).map { problems =>
44-
val diags = problems.map(CodeEditor.problemToDiagnostic(_, view.state.doc)).toJSArray
44+
val diags = problems.map { prob =>
45+
val diagnostic = CodeEditor.problemToDiagnostic(prob, view.state.doc)
46+
CodeEditor.problemToActions(prob, view.state.doc).foreach { actions =>
47+
diagnostic.setActions(actions.toJSArray)
48+
}
49+
diagnostic
50+
}.toJSArray
4551
view.update(js.Array(view.state.update(setDiagnostics(view.state, diags))))
4652
}
4753
} else None

metals-runner/src/main/scala/org/scastie/metals/DTOExtensions.scala

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import java.net.URI
1111
import scala.meta.pc.CancelToken
1212
import org.eclipse.lsp4j.Diagnostic
1313
import org.eclipse.lsp4j.DiagnosticSeverity
14+
import org.eclipse.lsp4j.CodeAction
1415
import org.scastie.api._
16+
import scala.jdk.CollectionConverters._
17+
import org.scastie.metals.JavaConverters._
1518

1619
object DTOExtensions {
1720
val wrapperIndent = " "
@@ -31,13 +34,24 @@ object DTOExtensions {
3134
val worksheetLineOffset = if isWorksheetMode then 1 else 0 // wrapper object
3235
val worksheetOffset = if isWorksheetMode then wrapperIndent.length else 0 // wrapper indent
3336

37+
val actions: Option[List[ScalaAction]] = Option(diagnostic.getData())
38+
.collect { case wrapper: java.util.List[_] => wrapper }
39+
.map { list =>
40+
list.asScala.toList.collect {
41+
case codeAction: CodeAction =>
42+
codeAction.toScalaAction(worksheetOffset, worksheetLineOffset)
43+
}
44+
}
45+
.filter(_.nonEmpty)
46+
3447
/* Diags are 1-based in editor but in compler they are 0-based, thus magic + 1 */
3548
Problem(
3649
diagSeverityToSeverity(diagnostic.getSeverity()),
3750
Option(diagnostic.getRange().getStart().getLine() - worksheetLineOffset + 1),
3851
Option(diagnostic.getRange().getStart().getCharacter() - worksheetOffset + 1),
3952
Option(diagnostic.getRange().getEnd().getCharacter() - worksheetOffset + 1),
40-
diagnostic.getMessage()
53+
diagnostic.getMessage(),
54+
actions
4155
)
4256

4357
extension (offsetParams: ScastieOffsetParams)

metals-runner/src/main/scala/org/scastie/metals/JavaConverters.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,40 @@ object JavaConverters {
127127
gson.fromJson(completionData.toString, classOf[CompletionItemData]).symbol
128128
}
129129

130+
extension (codeAction: CodeAction)
131+
def toScalaAction(worksheetOffset: Int, worksheetLineOffset: Int): ScalaAction = {
132+
val edit = Option(codeAction.getEdit).map { workspaceEdit =>
133+
val allChanges = Option(workspaceEdit.getChanges)
134+
.map { changesMap =>
135+
changesMap.asScala.values.flatMap(_.asScala).toList
136+
}
137+
.getOrElse(List.empty)
138+
139+
val scalaTextEdits = allChanges.map { textEdit =>
140+
val lspRange = textEdit.getRange
141+
val start = lspRange.getStart
142+
val end = lspRange.getEnd
143+
144+
val adjustedStartLine = start.getLine - worksheetLineOffset
145+
val adjustedStartChar = start.getCharacter - worksheetOffset
146+
val adjustedEndLine = end.getLine - worksheetLineOffset
147+
val adjustedEndChar = end.getCharacter - worksheetOffset
148+
149+
val range = DiagnosticRange(
150+
start = DiagnosticPosition(adjustedStartLine, adjustedStartChar),
151+
end = DiagnosticPosition(adjustedEndLine, adjustedEndChar)
152+
)
153+
ScalaTextEdit(range, textEdit.getNewText)
154+
}
155+
156+
ScalaWorkspaceEdit(scalaTextEdits)
157+
}
158+
159+
ScalaAction(
160+
title = codeAction.getTitle,
161+
description = Option(codeAction.getKind),
162+
edit = edit
163+
)
164+
}
165+
130166
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"@codemirror/commands": "^6.3.2",
1717
"@codemirror/language": "^6.9.3",
1818
"@codemirror/legacy-modes": "^6.3.3",
19-
"@codemirror/lint": "^6.4.2",
19+
"@codemirror/lint": "^6.9.2",
2020
"@codemirror/search": "^6.5.5",
2121
"@codemirror/state": "^6.3.3",
2222
"@codemirror/view": "^6.22.3",

scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import scala.jdk.CollectionConverters._
1717
import scala.jdk.OptionConverters._
1818
import scala.sys.process.{ Process, ProcessBuilder }
1919
import java.util.Optional
20-
import org.scastie.api.Problem
21-
import org.scastie.api.Severity
20+
import org.scastie.api.{Problem, Severity}
2221
import org.scastie.api
2322
import java.util.concurrent.TimeUnit
2423
import org.eclipse.lsp4j.jsonrpc.messages.CancelParams
@@ -48,6 +47,9 @@ import org.scastie.instrumentation.PositionMapper
4847

4948

5049
object BspClient {
50+
51+
private val gson = new Gson()
52+
5153
case class BuildOutput(process: ProcessBuilder, diagnostics: List[Problem])
5254

5355
sealed trait Runner {
@@ -96,32 +98,64 @@ object BspClient {
9698
else api.Error
9799
}
98100

101+
private def mapBspPosition(line: Int, char: Int, positionMapper: Option[PositionMapper], offset: Int): (Int, Int) = {
102+
positionMapper match {
103+
case Some(mapper) =>
104+
/* BSP is 0-indexed, mapper expects 1-indexed */
105+
val bspLine = line + 1
106+
/* Convert back to 0-indexed */
107+
(mapper.mapLine(bspLine) - 1, mapper.mapColumn(bspLine, char + 1) - 1)
108+
case None =>
109+
(line + offset, char)
110+
}
111+
}
112+
113+
private def convertBspActionToScastie(bspAction: ch.epfl.scala.bsp4j.ScalaAction, positionMapper: Option[PositionMapper], offset: Int): api.ScalaAction = {
114+
val edit = Option(bspAction.getEdit).map { bspEdit =>
115+
val changes = Option(bspEdit.getChanges)
116+
.map(_.asScala.toList.map { bspTextEdit =>
117+
val bspRange = bspTextEdit.getRange
118+
val (startLine, startChar) = mapBspPosition(bspRange.getStart.getLine, bspRange.getStart.getCharacter, positionMapper, offset)
119+
val (endLine, endChar) = mapBspPosition(bspRange.getEnd.getLine, bspRange.getEnd.getCharacter, positionMapper, offset)
120+
121+
val range = api.DiagnosticRange(
122+
start = api.DiagnosticPosition(line = startLine, character = startChar),
123+
end = api.DiagnosticPosition(line = endLine, character = endChar)
124+
)
125+
api.ScalaTextEdit(range = range, newText = bspTextEdit.getNewText)
126+
})
127+
.getOrElse(List.empty)
128+
129+
api.ScalaWorkspaceEdit(changes = changes)
130+
}
131+
132+
api.ScalaAction(
133+
title = bspAction.getTitle,
134+
description = Option(bspAction.getDescription),
135+
edit = edit
136+
)
137+
}
138+
99139
def diagnosticToProblem(isWorksheet: Boolean, positionMapper: Option[PositionMapper] = None)(diag: Diagnostic): Problem = {
100140
val offset = Instrument.getMessageLineOffset(isWorksheet)
141+
val bspRange = diag.getRange
101142

102-
val startLine = diag.getRange.getStart.getLine + 1
103-
val endLine = diag.getRange.getEnd.getLine + 1
143+
val (startLine, startChar) = mapBspPosition(bspRange.getStart.getLine, bspRange.getStart.getCharacter, positionMapper, offset)
144+
val (_, endChar) = mapBspPosition(bspRange.getEnd.getLine, bspRange.getEnd.getCharacter, positionMapper, offset)
104145

105-
val startColumn = Some(diag.getRange.getStart.getCharacter + 1)
106-
val endColumn = Some(diag.getRange.getEnd.getCharacter + 1)
107-
108-
val (mappedStartCol, mappedEndCol, mappedStartLine) = positionMapper match {
109-
case Some(mapper) =>
110-
(
111-
startColumn.map(col => mapper.mapColumn(startLine, col)),
112-
endColumn.map(col => mapper.mapColumn(startLine, col)),
113-
mapper.mapLine(startLine)
114-
)
115-
case None =>
116-
(startColumn, endColumn, startLine + offset)
117-
}
146+
val actions: Option[List[org.scastie.api.ScalaAction]] = for {
147+
data <- Option(diag.getData())
148+
scalaDiag <- Try(gson.fromJson(data.toString, classOf[ScalaDiagnostic])).toOption
149+
bspActions <- Option(scalaDiag.getActions())
150+
} yield bspActions.asScala.toList.map(bspAction => convertBspActionToScastie(bspAction, positionMapper, offset))
118151

119152
Problem(
120153
diagSeverityToSeverity(diag.getSeverity()),
121-
Option(mappedStartLine),
122-
mappedStartCol,
123-
mappedEndCol,
124-
diag.getMessage()
154+
Option(startLine + 1),
155+
Some(startChar + 1),
156+
Some(endChar + 1),
157+
diag.getMessage(),
158+
actions
125159
)
126160
}
127161

0 commit comments

Comments
 (0)