From ff75160d7ed617f872cf635b8dde90cb07e2af3e Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Wed, 1 Oct 2025 13:00:16 +0200 Subject: [PATCH 1/2] feat: improve error highlighting to use precise ranges - Errors are now highlighted using the actual error range (startColumn/endColumn), not the entire line. - Renamed LineMapper to PositionMapper, as it now maps both lines and columns (instrumentation can shift columns too). - Replaced all Int => Int usages with Option[PositionMapper]. - Added tests for column mapping in PositionMapperSpecs (formerly LineMapperSpecs). - Updated the Problem type: now includes severity, line, message, startColumn, and endColumn. --- .../scala/org/scastie/api/CompilerInfo.scala | 2 +- .../org/scastie/client/ScastieState.scala | 2 +- .../client/components/editor/CodeEditor.scala | 26 +++- .../scastie/instrumentation/Instrument.scala | 23 +-- .../instrumentation/InstrumentedInputs.scala | 28 ++-- .../scastie/instrumentation/LineMapper.scala | 67 --------- .../instrumentation/PositionMapper.scala | 122 ++++++++++++++++ ...rSpecs.scala => PositionMapperSpecs.scala} | 131 +++++++++++------- .../org/scastie/sbt/OutputExtractor.scala | 38 ++++- .../scala/org/scastie/sbt/SbtProcess.scala | 8 +- .../scala/org/scastie/sbt/SbtActorTest.scala | 38 +++-- .../scastie/sbt/plugin/CompilerReporter.scala | 8 +- .../org/scastie/scalacli/BspClient.scala | 37 ++++- .../org/scastie/scalacli/ScalaCliActor.scala | 2 +- .../org/scastie/scalacli/ScalaCliRunner.scala | 30 ++-- .../scastie/scalacli/ScalaCliRunnerTest.scala | 41 ++++-- 16 files changed, 410 insertions(+), 193 deletions(-) delete mode 100644 instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala create mode 100644 instrumentation/src/main/scala/org/scastie/instrumentation/PositionMapper.scala rename instrumentation/src/test/scala/org/scastie/instrumentation/{LineMapperSpecs.scala => PositionMapperSpecs.scala} (68%) diff --git a/api/src/main/scala/org/scastie/api/CompilerInfo.scala b/api/src/main/scala/org/scastie/api/CompilerInfo.scala index 2fd2cccdf..660a16bb9 100644 --- a/api/src/main/scala/org/scastie/api/CompilerInfo.scala +++ b/api/src/main/scala/org/scastie/api/CompilerInfo.scala @@ -18,4 +18,4 @@ object Problem { implicit val problemDecoder: Decoder[Problem] = deriveDecoder[Problem] } -case class Problem(severity: Severity, line: Option[Int], message: String) +case class Problem(severity: Severity, line: Option[Int], startColumn: Option[Int], endColumn: Option[Int], message: String) diff --git a/client/src/main/scala/org/scastie/client/ScastieState.scala b/client/src/main/scala/org/scastie/client/ScastieState.scala index 4b18a915c..7bcd6f49d 100644 --- a/client/src/main/scala/org/scastie/client/ScastieState.scala +++ b/client/src/main/scala/org/scastie/client/ScastieState.scala @@ -467,7 +467,7 @@ case class ScastieState( def clearSnippetId: ScastieState = copyAndSave(snippetId = None) - private def info(message: String) = Problem(Info, None, message) + private def info(message: String) = Problem(Info, None, None, None, message) def setForcedProgramMode(forcedProgramMode: Boolean): ScastieState = { if (!forcedProgramMode) this diff --git a/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala b/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala index 65400614b..42d46f66f 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala @@ -114,12 +114,32 @@ object CodeEditor { private def getDecorations(props: CodeEditor, doc: Text): js.Array[Diagnostic] = { val errors = props.compilationInfos - .filter(prob => prob.line.isDefined && prob.line.get <= doc.lines) + .filter(prob => prob.line.isDefined) .map(problem => { - val line = problem.line.get max 1 + val maxLine = doc.lines.toInt + val line = problem.line.get.max(1).min(maxLine) val lineInfo = doc.line(line) + val lineLength = lineInfo.length.toInt + + val (startColumn, endColumn) = + if (problem.line.get > maxLine) { + val endPos = lineInfo.to + (endPos, endPos) + } else { + (problem.startColumn, problem.endColumn) match { + case (Some(start), Some(end)) if start > 0 && end >= start => + val clampedStart = (start min (lineLength + 1)) max 1 + val clampedEnd = (end min (lineLength + 1)) max clampedStart + (lineInfo.from + clampedStart - 1, lineInfo.from + clampedEnd - 1) + case (Some(start), _) if start > 0 => + val clampedStart = (start min (lineLength + 1)) max 1 + (lineInfo.from + clampedStart - 1, lineInfo.from + clampedStart) + case _ => + (lineInfo.from, lineInfo.to) + } + } - Diagnostic(lineInfo.from, problem.message, parseSeverity(problem.severity), lineInfo.to) + Diagnostic(startColumn, problem.message, parseSeverity(problem.severity), endColumn) .setRenderMessage(CallbackTo { val wrapper = dom.document.createElement("pre") wrapper.innerHTML = HTMLFormatter.format(problem.message) diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala index 97a858018..51407a7fd 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala @@ -24,17 +24,13 @@ object InstrumentationFailure { case class InstrumentationSuccess( instrumentedCode: String, - lineMapping: Int => Int + positionMapper: Option[PositionMapper] = None ) object Instrument { def getParsingLineOffset(isWorksheet: Boolean): Int = if (isWorksheet) -1 else 0 def getExceptionLineOffset(isWorksheet: Boolean): Int = if (isWorksheet) -2 else 0 - def getMessageLineOffset(isWorksheet: Boolean, isScalaCli: Boolean): Int = (isWorksheet, isScalaCli) match { - case (true, _) => -2 - case (false, true) => 1 - case (false, false) => 0 - } + def getMessageLineOffset(isWorksheet: Boolean): Int = if (isWorksheet) -2 else 0 import InstrumentationFailure._ @@ -213,9 +209,14 @@ object Instrument { case target => scala(target.scalaVersion) } - val offset = target match { - case _: ScalaCli => usingDirectives.length + prelude.length + 1 - case _ => prelude.length + 1 + val isScalaCli = target match { + case _: ScalaCli => true + case _ => false + } + + val offset = isScalaCli match { + case true => usingDirectives.length + prelude.length + 1 + case false => prelude.length + 1 } maybeDialect match { @@ -228,11 +229,11 @@ object Instrument { if (!hasMainMethod(parsed.get)) { val (instrumentedCode, entryPoint) = instrument(parsed.get, offset, isScalaJs) - val lineMapping = LineMapper(instrumentedCode) + val positionMapper = PositionMapper(instrumentedCode, isScalaCli) Right(InstrumentationSuccess( s"""$instrumentedCode\n$entryPoint""", - lineMapping + Some(positionMapper) )) } else { Left(HasMainMethod) diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala index f02a0c4a7..e16801b90 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala @@ -8,12 +8,12 @@ import org.scastie.api._ import scala.meta.inputs.Input import scala.meta.parsers.Parsed -case class InstrumentationFailureReport(message: String, line: Option[Int]) { +case class InstrumentationFailureReport(message: String, line: Option[Int], startColumn: Option[Int] = None, endColumn: Option[Int] = None) { def toProgress(snippetId: SnippetId): SnippetProgress = { SnippetProgress.default.copy( ts = Some(Instant.now.toEpochMilli), snippetId = Some(snippetId), - compilationInfos = List(Problem(Error, line, message)) + compilationInfos = List(Problem(Error, line, startColumn, endColumn, message)) ) } } @@ -22,16 +22,16 @@ object InstrumentedInputs { def apply(inputs0: BaseInputs): Either[InstrumentationFailureReport, InstrumentedInputs] = { if (inputs0.isWorksheetMode) { val instrumented = Instrument(inputs0.code, inputs0.target).map { - case InstrumentationSuccess(instrumentedCode, lineMapper) => - (inputs0.copyBaseInput(code = instrumentedCode), lineMapper) + case InstrumentationSuccess(instrumentedCode, positionMapper) => + (inputs0.copyBaseInput(code = instrumentedCode), positionMapper) } instrumented match { - case Right((inputs, lineMapping)) => Right( + case Right((inputs, positionMapper)) => Right( InstrumentedInputs( inputs = inputs, isForcedProgramMode = false, - lineMapping = lineMapping + positionMapper = positionMapper ) ) case Left(error) => @@ -45,12 +45,20 @@ object InstrumentedInputs { Left(InstrumentationFailureReport("This Scala target does not have a worksheet mode", None)) case ParsingError(error) => - val lineOffset = Instrument.getParsingLineOffset(inputs0.isWorksheetMode) - val errorLine = (error.pos.startLine + lineOffset) max 1 + val isScalaCli = inputs0.target match { + case _: ScalaCli => true + case _ => false + } + val positionMapper = PositionMapper(error.pos.input.text, isScalaCli) + val errorLine = positionMapper.mapLine(error.pos.startLine + 1) max 1 + val errorStartCol = error.pos.startColumn + 1 + val errorEndCol = error.pos.endColumn + 1 + Right(InstrumentedInputs( inputs = inputs0.copyBaseInput(code = error.pos.input.text), isForcedProgramMode = false, - optionalParsingError = Some(InstrumentationFailureReport(error.message, Some(errorLine))), + optionalParsingError = Some(InstrumentationFailureReport(error.message, Some(errorLine), Some(errorStartCol), Some(errorEndCol))), + positionMapper = Some(positionMapper) )) case InternalError(exception) => @@ -73,5 +81,5 @@ case class InstrumentedInputs( inputs: BaseInputs, isForcedProgramMode: Boolean, optionalParsingError: Option[InstrumentationFailureReport] = None, - lineMapping: Int => Int = identity + positionMapper: Option[PositionMapper] = None ) diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala deleted file mode 100644 index 35e52d53e..000000000 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala +++ /dev/null @@ -1,67 +0,0 @@ -package org.scastie.instrumentation - -import RuntimeConstants._ - -object LineMapper { - - /** - * Creates a lineMapping that maps lines from instrumented Scastie code back to original code. - * - * @param instrumentedCode - * the code after Scastie instrumentation has been applied - * @return - * a lineMapping function for mapping line numbers - */ - def apply(instrumentedCode: String): Int => Int = { - buildSequentialMapping(instrumentedCode) - } - - private def buildSequentialMapping(instrumentedCode: String): Int => Int = { - val lines = instrumentedCode.split('\n') - val mappings = calculateSequentialMappings(lines) - - instrumentedLineNumber => mappings.getOrElse(instrumentedLineNumber, instrumentedLineNumber) - } - - private def calculateSequentialMappings(lines: Array[String]): Map[Int, Int] = { - - case class State(userCodeLinesSeen: Int = 0, mappings: Map[Int, Int] = Map.empty) - - lines.zipWithIndex - .foldLeft(State()) { case (State(userCodeLinesSeen, mappings), (line, index)) => - val instrumentedLineNumber = index + 1 - val trimmed = line.trim - - if (!isExperimentalImport(trimmed) && !isInstrumentationLine(trimmed)) { - val newCount = userCodeLinesSeen + 1 - State(newCount, mappings + (instrumentedLineNumber -> newCount)) - } else { - val fallbackLine = if (userCodeLinesSeen > 0) userCodeLinesSeen else 1 - State(userCodeLinesSeen, mappings + (instrumentedLineNumber -> fallbackLine)) - } - } - .mappings - } - - private def isInstrumentationLine(line: String): Boolean = { - line.matches("""\$doc\.startStatement\(\d+,\s*\d+\);""") || - line.matches("""\$doc\.endStatement\(\);""") || - line.matches("""\$doc\.binder\(.+,\s*\d+,\s*\d+\);""") || - line == "scala.Predef.locally {" || - line == "$t}" || - line.startsWith(s"import $runtimePackage") || - line.startsWith(s"object $instrumentedObject extends ScastieApp with $instrumentationRecorderT") || - line.startsWith("//> using") - } - - private def isWrappedUserCode(line: String): Boolean = { - line.startsWith("val $t = ") && - line.endsWith(";") - } - - private def isExperimentalImport(line: String): Boolean = { - val experimentalRegex = """^\s*import\s+language\.experimental\.[^\n]+""".r - experimentalRegex.matches(line) - } - -} diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/PositionMapper.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/PositionMapper.scala new file mode 100644 index 000000000..d529fe9c4 --- /dev/null +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/PositionMapper.scala @@ -0,0 +1,122 @@ +package org.scastie.instrumentation + +import RuntimeConstants._ +import org.scastie.api.ScalaTarget +import org.scastie.api.ScalaCli + +case class PositionMapper private ( + private val lineMapping: Int => Int, + private val columnOffsetMapping: Int => Int +) { + + /** + * Maps a line from instrumented code back to the original user code. + * + * @param line + * the line number in the instrumented code (1-based) + * @return + * the corresponding line number in the original user code (1-based) + */ + def mapLine(line: Int): Int = { + lineMapping(line) + } + + /** + * Maps a column from instrumented code back to the original user code, taking into account any column offsets + * + * @param line + * the line number in the instrumented code (1-based) + * @param column + * the column number in the instrumented code (1-based) + * @return + * the corresponding column number in the original user code (1-based) + */ + def mapColumn(line: Int, column: Int): Int = { + val offset = columnOffsetMapping(line) + math.max(1, column - offset) + } + +} + +object PositionMapper { + + private val instrumentationPrefix: String = "val $t = " + private val prefixOffset: Int = instrumentationPrefix.length + + /** + * Creates a PositionMapper that maps positions from instrumented Scastie code back to original code. + * + * @param instrumentedCode + * the code after Scastie instrumentation has been applied + * @param isScalaCli + * whether the target is Scala CLI + * @return + * a PositionMapper for mapping positions + */ + def apply(instrumentedCode: String, isScalaCli: Boolean = false): PositionMapper = { + val lines = instrumentedCode.split('\n') + val (lineMapping, columnOffsetMapping) = calculateMappings(lines, isScalaCli) + new PositionMapper(lineMapping, columnOffsetMapping) + } + + private def calculateMappings(lines: Array[String], isScalaCli: Boolean): (Int => Int, Int => Int) = { + case class State( + userCodeLinesSeen: Int = 0, + lineMappings: Map[Int, Int] = Map.empty, + columnOffsets: Map[Int, Int] = Map.empty + ) + + val result = lines.zipWithIndex + .foldLeft(State()) { case (State(userCodeLinesSeen, lineMappings, columnOffsets), (line, index)) => + val instrumentedLineNumber = index + 1 + val trimmed = line.trim + + val columnOffset = if (isWrappedUserCode(trimmed)) prefixOffset else 0 + + if (!isExperimentalImport(trimmed) && !isInstrumentationLine(trimmed, isScalaCli)) { + val newCount = userCodeLinesSeen + 1 + State( + newCount, + lineMappings + (instrumentedLineNumber -> newCount), + columnOffsets + (instrumentedLineNumber -> columnOffset) + ) + } else { + val fallbackLine = if (userCodeLinesSeen > 0) userCodeLinesSeen else 1 + State( + userCodeLinesSeen, + lineMappings + (instrumentedLineNumber -> fallbackLine), + columnOffsets + (instrumentedLineNumber -> columnOffset) + ) + } + } + + val lineMapping: Int => Int = + instrumentedLineNumber => result.lineMappings.getOrElse(instrumentedLineNumber, instrumentedLineNumber) + + val columnOffsetMapping: Int => Int = + instrumentedLineNumber => result.columnOffsets.getOrElse(instrumentedLineNumber, 0) + + (lineMapping, columnOffsetMapping) + } + + private def isInstrumentationLine(line: String, isScalaCli: Boolean): Boolean = { + line.matches("""\$doc\.startStatement\(\d+,\s*\d+\);""") || + line.matches("""\$doc\.endStatement\(\);""") || + line.matches("""\$doc\.binder\(.+,\s*\d+,\s*\d+\);""") || + line == "scala.Predef.locally {" || + line == "$t}" || + line.startsWith(s"import $runtimePackage") || + line.startsWith(s"object $instrumentedObject extends ScastieApp with $instrumentationRecorderT") || + (line.startsWith("//> using") && isScalaCli) + } + + private def isWrappedUserCode(line: String): Boolean = { + line.startsWith(instrumentationPrefix) + } + + private def isExperimentalImport(line: String): Boolean = { + val experimentalRegex = """^\s*import\s+language\.experimental\.[^\n]+""".r + experimentalRegex.matches(line) + } + +} diff --git a/instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala b/instrumentation/src/test/scala/org/scastie/instrumentation/PositionMapperSpecs.scala similarity index 68% rename from instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala rename to instrumentation/src/test/scala/org/scastie/instrumentation/PositionMapperSpecs.scala index a1ee4ab7c..4be90e076 100644 --- a/instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala +++ b/instrumentation/src/test/scala/org/scastie/instrumentation/PositionMapperSpecs.scala @@ -6,7 +6,7 @@ import scala.meta._ import org.scalatest.funsuite.AnyFunSuite import RuntimeConstants._ -class LineMapperSpecs extends AnyFunSuite { +class PositionMapperSpecs extends AnyFunSuite { test("identity mapping for identical input") { val code0 = s"""|val x = 42 @@ -16,10 +16,13 @@ class LineMapperSpecs extends AnyFunSuite { |val y = x + 1 |""".stripMargin - val lineMapper = LineMapper(code1) + val positionMapper = PositionMapper(code1) - assert(lineMapper(1) == 1) // val x = 42 - assert(lineMapper(2) == 2) // val y = x + 1 + assert(positionMapper.mapLine(1) == 1) // val x = 42 + assert(positionMapper.mapLine(2) == 2) // val y = x + 1 + + assert(positionMapper.mapColumn(1, 5) == 5) // no offset + assert(positionMapper.mapColumn(2, 10) == 10) // no offset } test("mapping with simple instrumentation") { @@ -46,11 +49,14 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(5) == 1) // val $t = println("test1"); + assert(positionMapper.mapLine(9) == 2) // val y = 1 + assert(positionMapper.mapLine(12) == 3) // val $t = println("test2"); - assert(lineMapping(5) == 1) // val $t = println("test1"); - assert(lineMapping(9) == 2) // val y = 1 - assert(lineMapping(12) == 3) // val $t = println("test2"); + assert(positionMapper.mapColumn(9, 5) == 5) // no offset + assert(positionMapper.mapColumn(12, 15) == 6) // offset of 9 } test("mapping with experimental imports extracted") { @@ -74,11 +80,14 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(2) == 1) // experimental import + assert(positionMapper.mapLine(7) == 2) // val $t = println("test"); + assert(positionMapper.mapLine(11) == 3) // val x = 1 - assert(lineMapping(2) == 1) // experimental import - assert(lineMapping(7) == 2) // val $t = println("test"); - assert(lineMapping(11) == 3) // val x = 1 + assert(positionMapper.mapColumn(7, 15) == 6) // offset of 9 + assert(positionMapper.mapColumn(11, 3) == 3) // no offset } test("mapping with multiline expressions") { @@ -100,11 +109,15 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) - assert(lineMapping(5) == 1) // val $t = println: - assert(lineMapping(6) == 2) // "multiline"; - assert(lineMapping(10) == 3) // val x = 1 + assert(positionMapper.mapLine(5) == 1) // val $t = println: + assert(positionMapper.mapLine(6) == 2) // "multiline"; + assert(positionMapper.mapLine(10) == 3) // val x = 1 + + // Test column mapping + assert(positionMapper.mapColumn(5, 12) == 3) // offset of 9 + assert(positionMapper.mapColumn(6, 5) == 5) // no offset } test("mapping with empty lines and comments") { @@ -130,13 +143,15 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(3) == 1) // Comment 1 + assert(positionMapper.mapLine(4) == 2) // empty line + assert(positionMapper.mapLine(7) == 3) // val $t = println("test"); + assert(positionMapper.mapLine(11) == 4) // Comment 2 + assert(positionMapper.mapLine(12) == 5) // val x = 1 - assert(lineMapping(3) == 1) // Comment 1 - assert(lineMapping(4) == 2) // empty line - assert(lineMapping(7) == 3) // val $t = println("test"); - assert(lineMapping(11) == 4) // Comment 2 - assert(lineMapping(12) == 5) // val x = 1 + assert(positionMapper.mapColumn(7, 20) == 11) // offset of 9 } test("mapping with mixed imports") { @@ -154,10 +169,12 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(4) == 1) // import scala.util.Random + assert(positionMapper.mapLine(6) == 3) // val r = Random.nextInt(); - assert(lineMapping(4) == 1) // import scala.util.Random - assert(lineMapping(6) == 3) // val r = Random.nextInt(); + assert(positionMapper.mapColumn(6, 10) == 10) // no offset } test("empty original code") { @@ -167,11 +184,13 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) - assert(lineMapping(1) == 1) - assert(lineMapping(2) == 1) - assert(lineMapping(3) == 1) + assert(positionMapper.mapLine(1) == 1) + assert(positionMapper.mapLine(2) == 1) + assert(positionMapper.mapLine(3) == 1) + + assert(positionMapper.mapColumn(1, 5) == 5) } test("single line code") { @@ -188,19 +207,25 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(5) == 1) // val $t = println(42); - assert(lineMapping(5) == 1) // val $t = println(42); + assert(positionMapper.mapColumn(5, 15) == 6) // offset of 9 } test("line numbers beyond input") { val code0 = "val x = 1" val code1 = "val x = 1" - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(10) == 10) + assert(positionMapper.mapLine(100) == 100) - assert(lineMapping(10) == 10) - assert(lineMapping(100) == 100) + // Test column mapping for lines beyond input + assert(positionMapper.mapColumn(10, 5) == 5) + assert(positionMapper.mapColumn(100, 20) == 20) } test("complex real-world example") { @@ -248,15 +273,18 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) - assert(lineMapping(4) == 1) // import scala.concurrent.Future - assert(lineMapping(7) == 4) // Setup comment - assert(lineMapping(8) == 5) // val data = List(1, 2, 3) - assert(lineMapping(10) == 7) // Processing comment - assert(lineMapping(13) == 8) // val $t = data.foreach... - assert(lineMapping(20) == 12) // val result = data.map(_ * 2) - assert(lineMapping(23) == 13) // val $t = println(result); + assert(positionMapper.mapLine(4) == 1) // import scala.concurrent.Future + assert(positionMapper.mapLine(7) == 4) // Setup comment + assert(positionMapper.mapLine(8) == 5) // val data = List(1, 2, 3) + assert(positionMapper.mapLine(10) == 7) // Processing comment + assert(positionMapper.mapLine(13) == 8) // val $t = data.foreach... + assert(positionMapper.mapLine(20) == 12) // val result = data.map(_ * 2) + assert(positionMapper.mapLine(23) == 13) // val $t = println(result); + + assert(positionMapper.mapColumn(13, 15) == 6) // offset of 9 + assert(positionMapper.mapColumn(23, 20) == 11) // offset of 9 } test("mapping with multiple identical expressions") { @@ -285,12 +313,15 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1) + + assert(positionMapper.mapLine(5) == 1) // val $t = println("test"); #1 + assert(positionMapper.mapLine(12) == 3) // val $t = println("test"); #2 + assert(positionMapper.mapLine(9) == 2) // val y = 2 + assert(positionMapper.mapLine(16) == 4) // val z = 3 - assert(lineMapping(5) == 1) // val $t = println("test"); #1 - assert(lineMapping(12) == 3) // val $t = println("test"); #2 - assert(lineMapping(9) == 2) // val y = 2 - assert(lineMapping(16) == 4) // val z = 3 + assert(positionMapper.mapColumn(5, 18) == 9) // offset of 9 + assert(positionMapper.mapColumn(12, 18) == 9) // offset of 9 } test("mapping for scala-cli") { @@ -314,8 +345,10 @@ class LineMapperSpecs extends AnyFunSuite { |} |""".stripMargin - val lineMapping = LineMapper(code1) + val positionMapper = PositionMapper(code1, true) + + assert(positionMapper.mapLine(9) == 3) // val $t = 1/0; - assert(lineMapping(9) == 3) // val $t = 1/0; + assert(positionMapper.mapColumn(9, 12) == 3) // offset of 9 } } diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala b/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala index 9b438e7a3..2461243d6 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala @@ -17,6 +17,8 @@ import RuntimeCodecs._ import scala.meta.inputs.Input import scala.util.control.NonFatal +import org.scastie.sbt +import org.scastie.instrumentation.PositionMapper class OutputExtractor(getScalaJsContent: () => Option[String], getScalaJsSourceMapContent: () => Option[String], @@ -25,7 +27,7 @@ class OutputExtractor(getScalaJsContent: () => Option[String], def extractProgress(output: ProcessOutput, sbtRun: SbtRun, isReloading: Boolean): SnippetProgress = { import sbtRun._ - val problems = extractProblems(output.line, sbtRun, Instrument.getMessageLineOffset(inputs.isWorksheetMode, isScalaCli = false)) + val problems = extractProblems(output.line, sbtRun, Instrument.getMessageLineOffset(inputs.isWorksheetMode)) val instrumentations = extract[List[Instrumentation]](output.line) val runtimeError = extractRuntimeError(output.line, sbtRun, Instrument.getExceptionLineOffset(inputs.isWorksheetMode)) val consoleOutput = extract[ConsoleOutput](output.line) @@ -128,6 +130,24 @@ class OutputExtractor(getScalaJsContent: () => Option[String], .getOrElse(sourceMapRaw) } + private def mapColumn(column: Option[Int], line: Option[Int], positionMapper: Option[PositionMapper] = None): Option[Int] = { + positionMapper match { + case Some(mapper) => + (column, line) match { + case (Some(c), Some(l)) => Some(mapper.mapColumn(l, c)) + case _ => column + } + case None => column + } + } + + private def mapLine(line: Option[Int], positionMapper: Option[PositionMapper] = None, offset: Int = 0): Option[Int] = { + positionMapper match { + case Some(mapper) => line.map(mapper.mapLine) + case None => line.map(_ + offset) + } + } + private def extractProblems( line: String, sbtRun: SbtRun, @@ -135,10 +155,12 @@ class OutputExtractor(getScalaJsContent: () => Option[String], ): Option[List[Problem]] = { val problems = extract[List[Problem]](line) - val problemsWithMappedLines = problems.map { + val problemsWithMappedPositions = problems.map { _.map(problem => - problem.copy(line = - problem.line.map(instrumentedLine => sbtRun.lineMapping(instrumentedLine)) + problem.copy( + line = mapLine(problem.line, sbtRun.positionMapper, lineOffset), + startColumn = mapColumn(problem.startColumn.map(_ + 1), problem.line, sbtRun.positionMapper), + endColumn = mapColumn(problem.endColumn.map(_ + 1), problem.line, sbtRun.positionMapper) ) ) } @@ -148,8 +170,8 @@ class OutputExtractor(getScalaJsContent: () => Option[String], in.message == "a pure expression does nothing in statement position; you may be omitting necessary parentheses" } - if (sbtRun.inputs.isWorksheetMode) problemsWithMappedLines.map(_.filterNot(annoying)) - else problemsWithMappedLines + if (sbtRun.inputs.isWorksheetMode) problemsWithMappedPositions.map(_.filterNot(annoying)) + else problemsWithMappedPositions } private def extractRuntimeError(line: String, sbtRun: SbtRun, lineOffset: Int): Option[RuntimeError] = { @@ -157,7 +179,9 @@ class OutputExtractor(getScalaJsContent: () => Option[String], _.error.map { error => val noStackTraceError = if (error.message.contains("No main class detected.")) error.copy(fullStack = "") else error val errorWithMappedLine = noStackTraceError.copy( - line = noStackTraceError.line.map(instrumentedLine => sbtRun.lineMapping(instrumentedLine)) + line = noStackTraceError.line.map( + line => mapLine(Some(line), sbtRun.positionMapper, lineOffset).getOrElse(line) + ) ) errorWithMappedLine } diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala b/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala index 0e4e68293..6b4adb696 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala @@ -10,7 +10,7 @@ import org.scastie.api._ import org.scastie.instrumentation.InstrumentedInputs import org.scastie.util.ScastieFileUtil.{slurp, write} import org.scastie.util._ -import org.scastie.instrumentation.LineMapper +import org.scastie.instrumentation.PositionMapper import scala.concurrent.duration._ import scala.util.Random @@ -31,7 +31,7 @@ object SbtProcess { progressActor: ActorRef, snippetActor: ActorRef, timeoutEvent: Option[Cancellable], - lineMapping: Int => Int = identity + positionMapper: Option[PositionMapper] = None ) extends Data case class SbtStateTimeout(duration: FiniteDuration, state: SbtState) { def message: String = { @@ -55,6 +55,8 @@ object SbtProcess { Problem( Error, line = None, + startColumn = None, + endColumn = None, message = message ) ) @@ -196,7 +198,7 @@ class SbtProcess(runTimeout: FiniteDuration, val sbtRun = _sbtRun.copy( inputs = instrumented.inputs.asInstanceOf[SbtInputs], isForcedProgramMode = instrumented.isForcedProgramMode, - lineMapping = instrumented.lineMapping + positionMapper = instrumented.positionMapper ) val isReloading = stateInputs.needsReload(sbtRun.inputs) setInputs(sbtRun.inputs) diff --git a/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala b/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala index 27604355c..684fbfe24 100644 --- a/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala +++ b/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala @@ -84,17 +84,27 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS } test("report parsing error") { - runCode("\n4444444444444444444\n", allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "integer number too large for Int") - assert(info.line.contains(2)) - }) + runCode("\nval x = 4444444444444444444\n", allowFailure = true)( + assertCompilationInfo( + assertProblemInfo("integer number too large for Int", Some(2), Some(9), Some(28))(_) + ) + ) + } + + test("report parsing error 2") { + runCode("val x = 1a", allowFailure = true)( + assertCompilationInfo( + assertProblemInfo("Invalid literal number, followed by identifier character", Some(1), Some(10), Some(10))(_) + ) + ) } test("report compilation error") { - runCode("err", allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "not found: value err") - assert(info.line.contains(1)) - }) + runCode("val x = err", allowFailure = true)( + assertCompilationInfo ( + assertProblemInfo("not found: value err", Some(1), Some(9), Some(12))(_) + ) + ) } test("Encoding issues #100") { @@ -512,6 +522,18 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS ) } + private def assertProblemInfo( + message: String = "", + line: Option[Int] = None, + startColumn: Option[Int] = None, + endColumn: Option[Int] = None + )(info: Problem): Unit = { + assert(info.message == message) + assert(info.line == line) + assert(info.startColumn == startColumn) + assert(info.endColumn == endColumn) + } + private def assertUserOutput( message: String, outputType: ProcessOutputType = ProcessOutputType.StdOut diff --git a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala index 545593c7d..1a710d700 100644 --- a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala +++ b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala @@ -41,7 +41,13 @@ object CompilerReporter { case xsbti.Severity.Warn => api.Warning case xsbti.Severity.Error => api.Error } - api.Problem(severity, toOption(p.position.line).map(_.toInt), p.message) + api.Problem( + severity, + toOption(p.position.line).map(_.toInt), + toOption(p.position.startColumn).map(_.toInt), + toOption(p.position.endColumn).map(_.toInt), + p.message + ) } if (problems.nonEmpty) { val apiProblems = problems.map(toApi) diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala index fd90c1f7c..a03bdfe5b 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala @@ -45,6 +45,7 @@ import scala.util.control.NonFatal import org.apache.commons.io.IOUtils import java.io.PrintWriter import java.lang +import org.scastie.instrumentation.PositionMapper object BspClient { @@ -92,12 +93,34 @@ object BspClient { else api.Error } - def diagnosticToProblem(isWorksheet: Boolean)(diag: Diagnostic): Problem = + def diagnosticToProblem(isWorksheet: Boolean, positionMapper: Option[PositionMapper] = None)(diag: Diagnostic): Problem = { + val offset = Instrument.getMessageLineOffset(isWorksheet) + + val startLine = diag.getRange.getStart.getLine + 1 + val endLine = diag.getRange.getEnd.getLine + 1 + + val startColumn = Some(diag.getRange.getStart.getCharacter + 1) + val endColumn = Some(diag.getRange.getEnd.getCharacter + 1) + + val (mappedStartCol, mappedEndCol, mappedStartLine) = positionMapper match { + case Some(mapper) => + ( + startColumn.map(col => mapper.mapColumn(startLine, col)), + endColumn.map(col => mapper.mapColumn(startLine, col)), + mapper.mapLine(startLine) + ) + case None => + (startColumn, endColumn, startLine + offset) + } + Problem( diagSeverityToSeverity(diag.getSeverity()), - Option(diag.getRange.getStart.getLine + 1), + Option(mappedStartLine), + mappedStartCol, + mappedEndCol, diag.getMessage() ) + } val scalaCliExec = Seq("cs", "launch", "org.virtuslab.scala-cli:cliBootstrapped:latest.release", "-M", "scala.cli.ScalaCli", "--") } @@ -200,12 +223,12 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout } } - private def compile(id: String, isWorksheet: Boolean, buildTargetId: BuildTargetIdentifier): BspTask[CompileResult] = EitherT { + private def compile(id: String, isWorksheet: Boolean, buildTargetId: BuildTargetIdentifier, positionMapper: Option[PositionMapper]): BspTask[CompileResult] = EitherT { val params: CompileParams = new CompileParams(Collections.singletonList(buildTargetId)) requestWithTimeout(_.buildTargetCompile(params))(using compilationTimeout).map(compileResult => compileResult.getStatusCode match { case StatusCode.OK => Right(compileResult) - case StatusCode.ERROR => Left(CompilationError(diagnostics.getAndSet(Nil).map(diagnosticToProblem(isWorksheet)))) + case StatusCode.ERROR => Left(CompilationError(diagnostics.getAndSet(Nil).map(diagnosticToProblem(isWorksheet, positionMapper)))) case StatusCode.CANCELLED => Left(InternalBspError("Compilation cancelled")) }) } @@ -258,19 +281,19 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout } } - def build(taskId: String, isWorksheet: Boolean, target: ScalaTarget): BspTask[BuildOutput] = { + def build(taskId: String, isWorksheet: Boolean, target: ScalaTarget, positionMapper: Option[PositionMapper]): BspTask[BuildOutput] = { println("Reloading") for { _ <- reloadWorkspace() _ = println("Build target") buildTarget <- getFirstBuildTarget() _ = println("Compile") - compileResult <- compile(taskId, isWorksheet, buildTarget.id) + compileResult <- compile(taskId, isWorksheet, buildTarget.id, positionMapper) _ = println("Get jvm") jvmRunEnvironment <- getJvmRunEnvironment(buildTarget.id) _ = println("Create process") process <- makeProcess(jvmRunEnvironment, buildTarget, isWorksheet) - } yield BuildOutput(process, diagnostics.getAndSet(Nil).map(diagnosticToProblem(isWorksheet))) + } yield BuildOutput(process, diagnostics.getAndSet(Nil).map(diagnosticToProblem(isWorksheet, positionMapper))) } // Kills the BSP connection and makes this object diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala index f26b0c862..eabe7b5ed 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala @@ -133,7 +133,7 @@ class ScalaCliActor( snippetId = Some(snippetId), isTimeout = isTimeout, isDone = true, - compilationInfos = List(Problem(Error, Some(-1), error)), + compilationInfos = List(Problem(Error, Some(-1), None, None, error)), buildOutput = Some(ProcessOutput(error, ProcessOutputType.StdErr, None)) ) } diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala index 85ee0c767..139f75031 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala @@ -32,6 +32,7 @@ import java.lang import java.util.concurrent.atomic.AtomicReference import cats.syntax.all._ import scala.jdk.FutureConverters._ +import org.scastie.instrumentation.PositionMapper sealed trait ScalaCliError { @@ -75,7 +76,7 @@ class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTi def runTask(snippetId: SnippetId, inputs: ScalaCliInputs, timeout: FiniteDuration, onOutput: ProcessOutput => Any): Future[Either[ScalaCliError, RunOutput]] = { log.info(s"Running task with snippetId=$snippetId") build(snippetId, inputs).flatMap { - case Right((value, lineMapping)) => runForked(value, inputs.isWorksheetMode, onOutput, lineMapping) + case Right((value, positionMapper)) => runForked(value, inputs.isWorksheetMode, onOutput, positionMapper) case Left(value) => Future.successful(Left[ScalaCliError, RunOutput](value)) } } @@ -83,29 +84,23 @@ class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTi def build( snippetId: SnippetId, inputs: BaseInputs, - ): Future[Either[ScalaCliError, (BspClient.BuildOutput, Int => Int)]] = { + ): Future[Either[ScalaCliError, (BspClient.BuildOutput, Option[PositionMapper])]] = { - val (instrumentedInput, lineMapping) = InstrumentedInputs(inputs) match { - case Right(value) => (value.inputs, value.lineMapping) + val (instrumentedInput, positionMapper) = InstrumentedInputs(inputs) match { + case Right(value) => (value.inputs, value.positionMapper) case Left(value) => log.error(s"Error while instrumenting: $value") - (inputs, identity: Int => Int) + (inputs, None) } Files.write(scalaMain, instrumentedInput.code.getBytes) - bspClient.build(snippetId.base64UUID, inputs.isWorksheetMode, inputs.target).value.recover { + bspClient.build(snippetId.base64UUID, inputs.isWorksheetMode, inputs.target, positionMapper).value.recover { case timeout: TimeoutException => BspTaskTimeout("Build Server Timeout Exception").asLeft case err => InternalBspError(err.getMessage).asLeft } .map { - case Right(buildOutput) => Right((buildOutput, lineMapping)) - case Left(CompilationError(diagnostics)) => - val mapped = diagnostics.map { p => - val orig = p.line - val mapped = orig.map(lineMapping) - p.copy(line = mapped) - } - Left(CompilationError(mapped)) + case Right(buildOutput) => Right((buildOutput, positionMapper)) + case Left(CompilationError(diagnostics)) => Left(CompilationError(diagnostics)) case Left(other) => Left(other) } } @@ -114,7 +109,7 @@ class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTi bspRun: BspClient.BuildOutput, isWorksheet: Boolean, onOutput: ProcessOutput => Any, - lineMapping: Int => Int = identity + positionMapper: Option[PositionMapper] = None ): Future[Either[ScastieRuntimeError, RunOutput]] = { val outputBuffer: ListBuffer[String] = ListBuffer() val instrumentations: AtomicReference[List[Instrumentation]] = new AtomicReference(List()) @@ -125,7 +120,10 @@ class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTi val maybeInstrumentation = decode[List[Instrumentation]](str) maybeRuntimeError.foreach { error => - val mappedLine = error.line.map(lineMapping) + val mappedLine = positionMapper match { + case Some(mapper) => error.line.map(mapper.mapLine) + case None => error.line + } runtimeError.set(Some(error.copy(line = mappedLine))) } maybeInstrumentation.foreach(instrumentations.set(_)) diff --git a/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala b/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala index e7c3eb23b..5ec06ef3f 100644 --- a/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala +++ b/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala @@ -64,7 +64,9 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with test("capture runtime errors") { runCode("1/0", allowFailure = true) { progress => progress.runtimeError.forall { error => - error.message.nonEmpty && error.message.contains("java.lang.ArithmeticException: / by zero") + error.message.nonEmpty && + error.message.contains("java.lang.ArithmeticException: / by zero") && + error.line.contains(1) } } } @@ -83,16 +85,27 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with } test("report parsing error") { - runCode("\n4444444444444444444\n", allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "number too large") - }) + runCode("\nval x = 4444444444444444444\n", allowFailure = true)( + assertCompilationInfo ( + assertProblemInfo("number too large", Some(2), Some(9), Some(28))(_) + ) + ) + } + + test("report parsing error 2") { + runCode("val x = 1a", allowFailure = true)( + assertCompilationInfo( + assertProblemInfo("Invalid literal number, followed by identifier character", Some(1), Some(10), Some(10))(_) + ) + ) } test("report compilation error") { - runCode("err", allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "Not found: err") - assert(info.line.contains(1)) - }) + runCode("val x = err", allowFailure = true)( + assertCompilationInfo ( + assertProblemInfo("Not found: err", Some(1), Some(9), Some(12))(_) + ) + ) } test("Encoding issues #100") { @@ -443,6 +456,18 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with run(ScalaCliInputs.default.copy(code = code, isWorksheetMode = isWorksheet), allowFailure)(fish) } + private def assertProblemInfo( + message: String = "", + line: Option[Int] = None, + startColumn: Option[Int] = None, + endColumn: Option[Int] = None + )(info: Problem): Unit = { + assert(info.message == message) + assert(info.line == line) + assert(info.startColumn == startColumn) + assert(info.endColumn == endColumn) + } + private def assertUserOutput( message: String, outputType: ProcessOutputType = ProcessOutputType.StdOut From c947c92e691be93d8b748a4adbd1abbe7c5f133e Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Fri, 24 Oct 2025 14:01:14 +0200 Subject: [PATCH 2/2] address comments --- .../client/components/editor/CodeEditor.scala | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala b/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala index 42d46f66f..fb0feea80 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala @@ -121,23 +121,27 @@ object CodeEditor { val lineInfo = doc.line(line) val lineLength = lineInfo.length.toInt - val (startColumn, endColumn) = + val preciseRangeOpt: Option[(Double, Double)] = if (problem.line.get > maxLine) { val endPos = lineInfo.to - (endPos, endPos) + Some((endPos, endPos)) } else { (problem.startColumn, problem.endColumn) match { case (Some(start), Some(end)) if start > 0 && end >= start => val clampedStart = (start min (lineLength + 1)) max 1 val clampedEnd = (end min (lineLength + 1)) max clampedStart - (lineInfo.from + clampedStart - 1, lineInfo.from + clampedEnd - 1) - case (Some(start), _) if start > 0 => - val clampedStart = (start min (lineLength + 1)) max 1 - (lineInfo.from + clampedStart - 1, lineInfo.from + clampedStart) + Some((lineInfo.from + clampedStart - 1, lineInfo.from + clampedEnd - 1)) case _ => - (lineInfo.from, lineInfo.to) + None } } + + val (startColumn, endColumn) = preciseRangeOpt match { + case Some((start, end)) => + (start, end) + case None => + (lineInfo.from, lineInfo.to) + } Diagnostic(startColumn, problem.message, parseSeverity(problem.severity), endColumn) .setRenderMessage(CallbackTo {