From 81d5da882cc6f434ed727b886bb95453601ada44 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 19 Mar 2026 13:18:00 +0100 Subject: [PATCH] Migrate from old `using_directives` to Scala-rewritten `directives-parser` module --- build.mill | 24 +- .../CustomDirectivesReporter.scala | 55 ----- .../preprocessing/ExtractedDirectives.scala | 104 ++++----- .../preprocessing/UsingDirectivesOps.scala | 55 ----- .../build/errors/NoValueProvidedError.scala | 7 - .../scala/cli/parse/CommentExtractor.scala | 153 ++++++++++++ .../scala/scala/cli/parse/Diagnostics.scala | 10 + .../scala/cli/parse/DirectiveValue.scala | 37 +++ .../main/scala/scala/cli/parse/Lexer.scala | 158 +++++++++++++ .../main/scala/scala/cli/parse/Parser.scala | 164 +++++++++++++ .../main/scala/scala/cli/parse/Position.scala | 4 + .../main/scala/scala/cli/parse/Token.scala | 42 ++++ .../scala/cli/parse/UsingDirective.scala | 15 ++ .../cli/parse/UsingDirectivesParser.scala | 57 +++++ .../cli/parse/CommentExtractorTests.scala | 104 +++++++++ .../scala/scala/cli/parse/LexerTests.scala | 124 ++++++++++ .../scala/scala/cli/parse/ParserTests.scala | 218 ++++++++++++++++++ .../directives/DirectiveValueParser.scala | 42 ++-- .../scala/build/directives/ScopedValue.scala | 4 +- .../errors/SingleValueExpectedError.scala | 2 +- .../directives/DirectiveHandler.scala | 59 ++--- .../directives/DirectiveUtil.scala | 42 ++-- .../preprocessing/directives/Exclude.scala | 1 - .../directives/StrictDirective.scala | 51 ++-- .../cli/integration/PublishSetupTests.scala | 26 +-- project/deps/package.mill | 1 - 26 files changed, 1245 insertions(+), 314 deletions(-) delete mode 100644 modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala delete mode 100644 modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala delete mode 100644 modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala create mode 100644 modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala create mode 100644 modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala create mode 100644 modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala create mode 100644 modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala diff --git a/build.mill b/build.mill index 83484c3460..6c46258183 100644 --- a/build.mill +++ b/build.mill @@ -98,6 +98,8 @@ object `specification-level` extends Cross[SpecificationLevel](Scala.scala3MainV with CrossScalaDefaultToInternal object `build-macros` extends Cross[BuildMacros](Scala.scala3MainVersions) with CrossScalaDefaultToInternal +object `directives-parser` extends Cross[DirectivesParserModule](Scala.scala3MainVersions) + with CrossScalaDefaultToInternal object config extends Cross[Config](Scala.scala3MainVersions) with CrossScalaDefaultToInternal object options extends Cross[Options](Scala.scala3MainVersions) @@ -605,7 +607,8 @@ trait Directives extends ScalaCliCrossSbtModule options(crossScalaVersion), core(crossScalaVersion), `build-macros`(crossScalaVersion), - `specification-level`(crossScalaVersion) + `specification-level`(crossScalaVersion), + `directives-parser`(crossScalaVersion) ) override def scalacOptions: T[Seq[String]] = Task { super.scalacOptions() ++ asyncScalacOptions(crossScalaVersion) @@ -619,8 +622,7 @@ trait Directives extends ScalaCliCrossSbtModule // Deps.asm, Deps.bloopConfig, Deps.jsoniterCore, - Deps.pprint, - Deps.usingDirectives + Deps.pprint ) override def repositoriesTask: Task[Seq[Repository]] = @@ -795,6 +797,16 @@ trait Build extends ScalaCliCrossSbtModule } } +trait DirectivesParserModule extends ScalaCliCrossSbtModule + with ScalaCliPublishModule + with HasTests + with ScalaCliScalafixModule + with LocatedInModules { + override def crossScalaVersion: String = crossValue + + object test extends ScalaCliTests with ScalaCliScalafixModule +} + trait SpecificationLevel extends ScalaCliCrossSbtModule with ScalaCliPublishModule with LocatedInModules { @@ -1013,6 +1025,9 @@ trait CliIntegration extends SbtModule ) trait IntegrationScalaTests extends super.ScalaCliTests with ScalaCliScalafixModule { + override def moduleDeps: Seq[JavaModule] = super.moduleDeps ++ Seq( + `directives-parser`(sv) + ) override def mvnDeps: T[Seq[Dep]] = super.mvnDeps() ++ Seq( Deps.bsp4j, Deps.coursier @@ -1021,8 +1036,7 @@ trait CliIntegration extends SbtModule Deps.jsoniterCore, Deps.libsodiumjni, Deps.pprint, - Deps.slf4jNop, - Deps.usingDirectives + Deps.slf4jNop ) override def compileMvnDeps: T[Seq[Dep]] = super.compileMvnDeps() ++ Seq( Deps.jsoniterMacros diff --git a/modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala b/modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala deleted file mode 100644 index 1e03d0bbb4..0000000000 --- a/modules/build/src/main/scala/scala/build/preprocessing/CustomDirectivesReporter.scala +++ /dev/null @@ -1,55 +0,0 @@ -package scala.build.preprocessing - -import com.virtuslab.using_directives.custom.utils.Position as DirectivePosition -import com.virtuslab.using_directives.reporter.Reporter - -import scala.build.Position -import scala.build.errors.{Diagnostic, Severity} - -class CustomDirectivesReporter(path: Either[String, os.Path], onDiagnostic: Diagnostic => Unit) - extends Reporter { - private var errorCount = 0 - private var warningCount = 0 - - private def toScalaCliPosition(position: DirectivePosition): Position = { - val coords = (position.getLine, position.getColumn) - Position.File(path, coords, coords) - } - - override def error(msg: String): Unit = - onDiagnostic { - errorCount += 1 - Diagnostic(msg, Severity.Error) - } - override def error(position: DirectivePosition, msg: String): Unit = - onDiagnostic { - errorCount += 1 - Diagnostic(msg, Severity.Error, Seq(toScalaCliPosition(position))) - } - override def warning(msg: String): Unit = - onDiagnostic { - warningCount += 1 - Diagnostic(msg, Severity.Warning) - } - override def warning(position: DirectivePosition, msg: String): Unit = - onDiagnostic { - warningCount += 1 - Diagnostic(msg, Severity.Warning, Seq(toScalaCliPosition(position))) - } - - override def hasErrors(): Boolean = - errorCount != 0 - - override def hasWarnings(): Boolean = - warningCount != 0 - - override def reset(): Unit = { - errorCount = 0 - } -} - -object CustomDirectivesReporter { - def create(path: Either[String, os.Path])(onDiagnostic: Diagnostic => Unit) - : CustomDirectivesReporter = - new CustomDirectivesReporter(path, onDiagnostic) -} diff --git a/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala b/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala index bba577ca0f..1bbc18b141 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/ExtractedDirectives.scala @@ -1,17 +1,12 @@ package scala.build.preprocessing -import com.virtuslab.using_directives.UsingDirectivesProcessor -import com.virtuslab.using_directives.custom.model.{BooleanValue, EmptyValue, StringValue, Value} -import com.virtuslab.using_directives.custom.utils.ast.* - import scala.annotation.targetName import scala.build.errors.* import scala.build.options.SuppressWarningOptions -import scala.build.preprocessing.UsingDirectivesOps.* import scala.build.preprocessing.directives.StrictDirective import scala.build.{Logger, Position} +import scala.cli.parse.{DiagnosticSeverity, DirectiveValue, UsingDirectivesParser} import scala.collection.mutable -import scala.jdk.CollectionConverters.* case class ExtractedDirectives( directives: Seq[StrictDirective], @@ -33,65 +28,68 @@ object ExtractedDirectives { logger: Logger, maybeRecoverOnError: BuildException => Option[BuildException] ): Either[BuildException, ExtractedDirectives] = { - val errors = new mutable.ListBuffer[Diagnostic] - val reporter = CustomDirectivesReporter - .create(path) { - case diag - if diag.severity == Severity.Warning && - diag.message.toLowerCase.contains("deprecated") && - suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) => - () // skip deprecated feature warnings if suppressed - case diag if diag.severity == Severity.Warning => - logger.log(Seq(diag)) - case diag => errors += diag - } - val processor = new UsingDirectivesProcessor(reporter) - val allDirectives = processor.extract(contentChars).asScala + val result = UsingDirectivesParser.parse(contentChars) + val diagnosticErrors = mutable.ListBuffer.empty[Diagnostic] + + for diag <- result.diagnostics do + val positions = diag.position.map { p => + Position.File(path, (p.line, p.column), (p.line, p.column)) + }.toSeq + + if diag.severity == DiagnosticSeverity.Warning then + if diag.message.toLowerCase.contains("deprecated") && + suppressWarningOptions.suppressDeprecatedFeatureWarning.getOrElse(false) + then () // skip + else logger.log(Seq(Diagnostic(diag.message, Severity.Warning, positions))) + else + diagnosticErrors += Diagnostic(diag.message, Severity.Error, positions) + val malformedDirectiveErrors = - errors.map(diag => new MalformedDirectiveError(diag.message, diag.positions)).toSeq + diagnosticErrors + .map(diag => new MalformedDirectiveError(diag.message, diag.positions)) + .toSeq + val maybeCompositeMalformedDirectiveError = - if (malformedDirectiveErrors.nonEmpty) + if malformedDirectiveErrors.nonEmpty then maybeRecoverOnError(CompositeBuildException(malformedDirectiveErrors)) else None - if (malformedDirectiveErrors.isEmpty || maybeCompositeMalformedDirectiveError.isEmpty) { - val directivesOpt = allDirectives.headOption - val directivesPositionOpt = directivesOpt match { - case Some(directives) - if directives.containsTargetDirectives || - directives.isEmpty => None - case Some(directives) => Some(directives.getPosition(path)) - case None => None - } + if malformedDirectiveErrors.isEmpty || maybeCompositeMalformedDirectiveError.isEmpty then + val directives = result.directives + + val containsTargetDirectives = directives.exists(_.key.startsWith("target.")) - val strictDirectives = directivesOpt.toSeq.flatMap { directives => - def toStrictValue(value: UsingValue): Seq[Value[?]] = value match { - case uvs: UsingValues => uvs.values.asScala.toSeq.flatMap(toStrictValue) - case el: EmptyLiteral => Seq(EmptyValue(el)) - case sl: StringLiteral => Seq(StringValue(sl.getValue(), sl)) - case bl: BooleanLiteral => Seq(BooleanValue(bl.getValue(), bl)) - } - def toStrictDirective(ud: UsingDef) = - StrictDirective( - ud.getKey(), - toStrictValue(ud.getValue()), - ud.getPosition().getColumn(), - ud.getPosition().getLine() - ) + val directivesPositionOpt = + if containsTargetDirectives || directives.isEmpty then None + else + val lastDirective = directives.last + val (endLine, endCol) = lastDirective.values.lastOption match + case Some(sv: DirectiveValue.StringVal) if sv.isQuoted => + (sv.pos.line, sv.pos.column + sv.value.length + 2) + case Some(sv: DirectiveValue.StringVal) => + (sv.pos.line, sv.pos.column + sv.value.length) + case Some(bv: DirectiveValue.BoolVal) => + (bv.pos.line, bv.pos.column + bv.value.toString.length) + case Some(ev: DirectiveValue.EmptyVal) => + (ev.pos.line, ev.pos.column) + case None => + val kp = lastDirective.keyPosition + (kp.line, kp.column + lastDirective.key.length) + Some(Position.File(path, (0, 0), (endLine, endCol), result.codeOffset)) - directives.getAst match - case uds: UsingDefs => uds.getUsingDefs.asScala.toSeq.map(toStrictDirective) - case ud: UsingDef => Seq(toStrictDirective(ud)) - case _ => Nil // There should be nothing else here other than UsingDefs or UsingDef + val strictDirectives = directives.map { ud => + StrictDirective( + ud.key, + ud.values, + ud.keyPosition.column, + ud.keyPosition.line + ) } Right(ExtractedDirectives(strictDirectives.reverse, directivesPositionOpt)) - } else - maybeCompositeMalformedDirectiveError match { + maybeCompositeMalformedDirectiveError match case Some(e) => Left(e) case None => Right(ExtractedDirectives.empty) - } } - } diff --git a/modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala b/modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala deleted file mode 100644 index 9409dd876c..0000000000 --- a/modules/build/src/main/scala/scala/build/preprocessing/UsingDirectivesOps.scala +++ /dev/null @@ -1,55 +0,0 @@ -package scala.build.preprocessing - -import com.virtuslab.using_directives.custom.model.UsingDirectives -import com.virtuslab.using_directives.custom.utils.ast.* - -import scala.annotation.tailrec -import scala.build.Position -import scala.jdk.CollectionConverters.* - -object UsingDirectivesOps { - extension (ud: UsingDirectives) { - def keySet: Set[String] = ud.getFlattenedMap.keySet().asScala.map(_.toString).toSet - def containsTargetDirectives: Boolean = ud.keySet.exists(_.startsWith("target.")) - - def getPosition(path: Either[String, os.Path]): Position.File = - extension (pos: Positioned) { - def getLine = pos.getPosition.getLine - def getColumn = pos.getPosition.getColumn - } - - @tailrec - def getEndPostion(ast: UsingTree): (Int, Int) = ast match { - case uds: UsingDefs => uds.getUsingDefs.asScala match { - case _ :+ lastUsingDef => getEndPostion(lastUsingDef) - case _ => (uds.getLine, uds.getColumn) - } - case ud: UsingDef => getEndPostion(ud.getValue) - case uvs: UsingValues => uvs.getValues.asScala match { - case _ :+ lastUsingValue => getEndPostion(lastUsingValue) - case _ => (uvs.getLine, uvs.getColumn) - } - case sl: StringLiteral => ( - sl.getLine, - sl.getColumn + sl.getValue.length + { if sl.getIsWrappedDoubleQuotes then 2 else 0 } - ) - case bl: BooleanLiteral => (bl.getLine, bl.getColumn + bl.getValue.toString.length) - case el: EmptyLiteral => (el.getLine, el.getColumn) - } - - val (line, column) = getEndPostion(ud.getAst) - - Position.File(path, (0, 0), (line, column), ud.getCodeOffset) - - def getDirectives = - ud.getAst match { - case usingDefs: UsingDefs => - usingDefs.getUsingDefs.asScala.toSeq - case _ => - Nil - } - - def nonEmpty: Boolean = !isEmpty - def isEmpty: Boolean = ud.getFlattenedMap.isEmpty - } -} diff --git a/modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala b/modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala deleted file mode 100644 index 5dbcc48a10..0000000000 --- a/modules/core/src/main/scala/scala/build/errors/NoValueProvidedError.scala +++ /dev/null @@ -1,7 +0,0 @@ -package scala.build.errors - -final class NoValueProvidedError(val key: String) extends BuildException( - s"Expected a value for directive $key", - // TODO - this seems like outdated thing - positions = Nil // I wish using_directives provided the key position… - ) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala new file mode 100644 index 0000000000..6dfcebfc2a --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/CommentExtractor.scala @@ -0,0 +1,153 @@ +package scala.cli.parse + +/** Represents a single `//> using ...` directive line extracted from a source file. */ +case class DirectiveLine( + /** The full text of the line, including the `//> ` prefix (no trailing newline). */ + content: String, + /** 0-indexed line number in the original source file. */ + lineNum: Int, + /** Absolute byte offset of the first character of this line in the source file. */ + lineStartOffset: Int +) + +/** Result of the comment extraction phase. */ +case class ExtractorResult( + directiveLines: Seq[DirectiveLine], + codeOffset: Int, + diagnostics: Seq[UsingDirectiveDiagnostic] +) + +/** Phase 1: scans a source file and extracts `//> using` directive lines. + * + * Rules: + * - Lines beginning with `#!` (shebang) are allowed only as the very first line. + * - Blank lines are allowed anywhere in the directive region. + * - Line comments (`//` not `//> `) are allowed and skipped. + * - Block comments (`/* ... */`, including multi-line) are allowed and skipped. + * - The first line that is none of the above marks the start of code (`codeOffset`). + * - Any `//> using` lines that appear after `codeOffset` are NOT included in the result, but a + * warning diagnostic is emitted for each one. + * - Directives inside block comments are NOT parsed. + */ +object CommentExtractor: + + def extract(content: Array[Char]): ExtractorResult = + val length = content.length + val diagnostics = scala.collection.mutable.ArrayBuffer.empty[UsingDirectiveDiagnostic] + val directives = scala.collection.mutable.ArrayBuffer.empty[DirectiveLine] + + var offset = 0 + var lineNum = 0 + var codeStart = -1 // -1 means not yet found + + def currentLineText(lineStartOff: Int): String = + var end = lineStartOff + while end < length && content(end) != '\n' do end += 1 + new String(content, lineStartOff, end - lineStartOff) + + // Skip a block comment starting at `offset` (which points to `/` of `/*`). + // Returns the offset just after the closing `*/`, updating `lineNum`. + def skipBlockComment(startOff: Int, startLine: Int): (Int, Int) = + var off = startOff + 2 // skip `/*` + var ln = startLine + while off < length - 1 && !(content(off) == '*' && content(off + 1) == '/') do + if content(off) == '\n' then ln += 1 + off += 1 + if off < length - 1 then + off += 2 // skip `*/` + (off, ln) + + while offset < length && codeStart < 0 do + val lineStart = offset + + // Determine what kind of line this is (without consuming it yet) + val lineContent = currentLineText(lineStart) + val trimmed = lineContent.stripLeading() + + if trimmed.isEmpty then + // Blank line: skip + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if lineNum == 0 && trimmed.startsWith("#!") then + // Shebang: skip only on the very first line + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if trimmed.startsWith("/*") then + // Block comment: skip the whole comment, may span multiple lines + val commentStartOff = lineStart + lineContent.indexOf("/*") + val (afterComment, newLine) = + skipBlockComment(commentStartOff, lineNum) + // After the block comment, check if the rest of the line (if any) is blank + // Find the end of the current logical "section" that covers the block comment + // We need to advance `offset` and `lineNum` past the block comment. + // Also check if the block comment ends on the same line and there's more content. + offset = afterComment + lineNum = newLine + // Skip to end of the current line (in case there's trailing whitespace after `*/`) + while offset < length && content(offset) != '\n' do + val c = content(offset) + if c != ' ' && c != '\t' && c != '\r' then + // Non-blank, non-comment content after `*/` on the same line → code starts + codeStart = lineStart + offset += 1 + if codeStart < 0 then + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if trimmed.startsWith("//") && !trimmed.startsWith("//>") then + // Line comment (not a directive): skip + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else if trimmed.startsWith("//>") then + // Potential directive line + val withoutLeading = lineContent.dropWhile(c => c == ' ' || c == '\t') + if withoutLeading.startsWith("//> using") || withoutLeading.startsWith("//>using") then + // Normalize: ensure there's a space after `//>` + val effectiveContent = + if withoutLeading.startsWith("//> ") then lineContent + else lineContent // keep as-is; the lexer will handle it + directives += DirectiveLine(effectiveContent, lineNum, lineStart) + else + // `//> ` but not followed by `using` — treat as code + codeStart = lineStart + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + lineNum += 1 + else + // First code line + codeStart = lineStart + + // If we never found code, codeOffset is end of file + val codeOffset = if codeStart >= 0 then codeStart else length + + // Continue scanning the rest of the file for post-code directives + if codeStart >= 0 then + offset = codeStart + var ln = lineNum + + while offset < length do + val lineStart = offset + val lineContent = currentLineText(lineStart) + val trimmed = lineContent.stripLeading() + + if trimmed.startsWith("/*") then + // Skip block comment + val commentStartOff = lineStart + lineContent.indexOf("/*") + val (afterComment, newLine) = skipBlockComment(commentStartOff, ln) + offset = afterComment + ln = newLine + while offset < length && content(offset) != '\n' do offset += 1 + if offset < length && content(offset) == '\n' then offset += 1 + ln += 1 + else + val linePos = Some(Position(ln, 0, lineStart)) + if trimmed.startsWith("//> using") || trimmed.startsWith("//>using") then + val msg = s"Ignoring using directive found after Scala code: ${trimmed.trim}" + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Warning, linePos) + offset = lineStart + lineContent.length + if offset < length && content(offset) == '\n' then offset += 1 + ln += 1 + + ExtractorResult(directives.toSeq, codeOffset, diagnostics.toSeq) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala new file mode 100644 index 0000000000..80de2b0bb7 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Diagnostics.scala @@ -0,0 +1,10 @@ +package scala.cli.parse + +enum DiagnosticSeverity: + case Error, Warning + +case class UsingDirectiveDiagnostic( + message: String, + severity: DiagnosticSeverity, + position: Option[Position] = None +) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala new file mode 100644 index 0000000000..30437804b1 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/DirectiveValue.scala @@ -0,0 +1,37 @@ +package scala.cli.parse + +enum DirectiveValue: + /** A string value, either quoted (`"hello"`) or bare (`hello`). */ + case StringVal(value: String, isQuoted: Boolean, position: Position) + + /** A boolean literal (`true` or `false`). */ + case BoolVal(value: Boolean, position: Position) + + /** No value was provided after the key (treated as `true` downstream). */ + case EmptyVal(position: Position) + +object DirectiveValue: + extension (dv: DirectiveValue) + + def pos: Position = dv match + case StringVal(_, _, p) => p + case BoolVal(_, p) => p + case EmptyVal(p) => p + + /** Raw source text representation (e.g. `"hello"` with quotes for quoted strings). */ + def rawText: String = dv match + case StringVal(v, true, _) => s""""$v"""" + case StringVal(v, false, _) => v + case BoolVal(v, _) => v.toString + case EmptyVal(_) => "" + + /** The string content of the value (without quotes), or boolean/empty as string. */ + def stringValue: String = dv match + case StringVal(v, _, _) => v + case BoolVal(v, _) => v.toString + case EmptyVal(_) => "" + + /** Whether this value is a quoted string literal. */ + def isQuotedString: Boolean = dv match + case StringVal(_, true, _) => true + case _ => false diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala new file mode 100644 index 0000000000..d93116b5d8 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Lexer.scala @@ -0,0 +1,158 @@ +package scala.cli.parse + +/** Tokenizes the content of a single `//> using` directive line. + * + * The lexer receives the full line text (including the `//> ` prefix), the line number in the + * original file (0-indexed), and the absolute byte offset of the start of the line in the file. It + * produces a sequence of [[Token]]s with positions relative to the original file. + * + * Comma rule: a `,` is a [[Token.Comma]] separator only if immediately followed by whitespace or + * end of content. A `,` embedded in a non-whitespace sequence is part of the identifier/value + * token. This matches the behaviour of the original `using_directives` Java library. + */ +object Lexer: + + /** Tokenize a directive line into a flat [[Token]] sequence. + * + * @param lineText + * the full text of the line (e.g. `//> using dep com.lihaoyi::os-lib:0.11.4`) + * @param lineNum + * 0-indexed line number of this line in the original file + * @param lineStartOffset + * absolute byte offset of the first character of this line in the original file + * @return + * tokens produced from this line, ending with [[Token.Newline]] + */ + def tokenize(lineText: String, lineNum: Int, lineStartOffset: Int): Seq[Token] = + val chars = lineText.toArray + val length = chars.length + val buf = scala.collection.mutable.ArrayBuffer.empty[Token] + + def pos(col: Int): Position = + Position(lineNum, col, lineStartOffset + col) + + var col = 0 + + def skipDirectivePrefix(): Unit = + // Skip `//> ` (4 chars). The first character of useful content starts at col 4. + col = 4 + + def isWhitespace(c: Char): Boolean = + c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' + + skipDirectivePrefix() + + while col < length do + val c = chars(col) + if isWhitespace(c) then col += 1 + else if c == ',' && (col + 1 >= length || isWhitespace(chars(col + 1))) then + // Standalone comma: deprecated separator + buf += Token.Comma(pos(col)) + col += 1 + else if c == '`' then + // Backtick-quoted identifier: strip backticks, treat content as a bare Ident + val startCol = col + col += 1 + val sb = new StringBuilder + var closed = false + while col < length && !closed do + chars(col) match + case '`' => + closed = true + col += 1 + case other => + sb += other + col += 1 + val text = sb.toString + if !closed then + buf += Token.LexError("Unterminated backtick identifier", pos(startCol)) + else if text.nonEmpty then + buf += Token.Ident(text, pos(startCol)) + else if c == '"' then + // Double-quoted string literal + val startCol = col + col += 1 + val sb = new StringBuilder + var closed = false + while col < length && !closed do + chars(col) match + case '"' => + closed = true + col += 1 + case '\\' if col + 1 < length => + col += 1 + val escaped = chars(col) match + case 'n' => '\n' + case 't' => '\t' + case 'r' => '\r' + case '\\' => '\\' + case '"' => '"' + case 'u' if col + 4 < length => + val hex = lineText.substring(col + 1, col + 5) + col += 4 + Integer.parseInt(hex, 16).toChar + case other => other + sb += escaped + col += 1 + case other => + sb += other + col += 1 + if !closed then + buf += Token.LexError("Unterminated string literal", pos(startCol)) + else + buf += Token.StringLit(sb.toString, pos(startCol)) + else + // Bare identifier / value: consume until whitespace or (comma + whitespace/end) + val startCol = col + val sb = new StringBuilder + var stop = false + while col < length && !stop do + val ch = chars(col) + if isWhitespace(ch) then stop = true + else if ch == '"' then stop = true + else if ch == ',' && (col + 1 >= length || isWhitespace(chars(col + 1))) then + // Comma followed by whitespace/end: stop here, don't consume the comma + stop = true + else + sb += ch + col += 1 + val text = sb.toString + if text.nonEmpty then + text match + case "using" => buf += Token.Using(pos(startCol)) + case "true" => buf += Token.BoolLit(true, pos(startCol)) + case "false" => buf += Token.BoolLit(false, pos(startCol)) + case _ => + // Check if this looks like a dotted key segment (may have internal dots) + // We emit Dot tokens for '.' characters between identifiers in key position; + // the Parser handles the distinction between key and value contexts. + // For simplicity, emit the whole bare token as Ident — the Parser splits on dots. + buf += Token.Ident(text, pos(startCol)) + + buf += Token.Newline(pos(length)) + buf.toSeq + + /** Tokenize a directive line and split dotted key parts into separate Ident + Dot tokens. + * + * This is used by the [[Parser]] to handle keys like `test.dep` where each segment is a separate + * Ident token joined by Dot tokens, while keeping values (which may contain `.`) as single Ident + * tokens. + * + * This method re-tokenizes an Ident that appears immediately after `using` (or after another + * Ident+Dot sequence) into its dotted components. + */ + def splitDottedIdent(ident: String, startPos: Position): Seq[Token] = + val parts = ident.split('.').toSeq + if parts.length <= 1 then Seq(Token.Ident(ident, startPos)) + else + val buf = scala.collection.mutable.ArrayBuffer.empty[Token] + var offset = 0 + parts.zipWithIndex.foreach: (part, i) => + val partPos = Position(startPos.line, startPos.column + offset, startPos.offset + offset) + buf += Token.Ident(part, partPos) + offset += part.length + if i < parts.length - 1 then + buf += + Token.Dot(Position(startPos.line, startPos.column + offset, startPos.offset + offset)) + offset += 1 + buf.toSeq diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala new file mode 100644 index 0000000000..d9781a0842 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Parser.scala @@ -0,0 +1,164 @@ +package scala.cli.parse + +import scala.annotation.tailrec + +/** Phase 3: recursive-descent parser that consumes a token stream and produces [[UsingDirective]] + * nodes. + * + * Grammar: + * {{{ + * Directives ::= { Directive } + * Directive ::= Using Key Values Newline + * Key ::= Ident (dotted keys are single Ident tokens, e.g. "test.dep") + * Values ::= { [Comma] Value } + * Value ::= StringLit | BoolLit | Ident + * }}} + * + * When Values is empty (key immediately followed by Newline), a single [[DirectiveValue.EmptyVal]] + * is produced. + * + * Error recovery: on unexpected token, a diagnostic is emitted and the parser skips to the next + * Newline token. + */ +object Parser: + + private def tokenKindName(t: Token): String = + t match + case _: Token.Using => "Using" + case _: Token.Ident => "Ident" + case _: Token.StringLit => "StringLit" + case _: Token.BoolLit => "BoolLit" + case _: Token.Dot => "Dot" + case _: Token.Comma => "Comma" + case _: Token.Newline => "Newline" + case _: Token.Eof => "Eof" + case _: Token.LexError => "LexError" + + def parse(tokens: Seq[Token]): (Seq[UsingDirective], Seq[UsingDirectiveDiagnostic]) = + val diagnostics = scala.collection.mutable.ArrayBuffer.empty[UsingDirectiveDiagnostic] + val directives = scala.collection.mutable.ArrayBuffer.empty[UsingDirective] + val arr = tokens.toIndexedSeq + var pos = 0 + + def current: Token = if pos < arr.length then arr(pos) else Token.Eof(Position(0, 0, 0)) + + def advance(): Unit = if pos < arr.length then pos += 1 + + def skipToNewline(): Unit = + @tailrec + def skipUntilBoundary(): Unit = + current match + case _: Token.Newline | _: Token.Eof => () + case _ => + advance() + skipUntilBoundary() + skipUntilBoundary() + current match + case _: Token.Newline => advance() + case _ => () + + def error(msg: String, p: Position): Unit = + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Error, Some(p)) + + def warn(msg: String, p: Position): Unit = + diagnostics += UsingDirectiveDiagnostic(msg, DiagnosticSeverity.Warning, Some(p)) + + def parseValues(): Seq[DirectiveValue] = + val values = scala.collection.mutable.ArrayBuffer.empty[DirectiveValue] + + @tailrec + def loop(): Seq[DirectiveValue] = + current match + case _: Token.Newline | _: Token.Eof => + values.toSeq + case Token.Comma(p) => + // One diagnostic per comma (matches legacy `using_directives` / integration tests). + warn( + "Use of commas as separators is deprecated. Only whitespace is necessary.", + p + ) + advance() + loop() + case Token.StringLit(v, p) => + values += DirectiveValue.StringVal(v, isQuoted = true, p) + advance() + loop() + case Token.BoolLit(v, p) => + values += DirectiveValue.BoolVal(v, p) + advance() + loop() + case Token.Ident(v, p) => + values += DirectiveValue.StringVal(v, isQuoted = false, p) + advance() + loop() + case Token.Using(p) => + values += DirectiveValue.StringVal("using", isQuoted = false, p) + advance() + loop() + case Token.LexError(msg, p) => + error(s"Lexer error: $msg", p) + advance() + loop() + case t => + error(s"Unexpected token in directive values: ${tokenKindName(t)}", t.pos) + skipToNewline() + values.toSeq + + loop() + + @tailrec + def parseDirectives(): Unit = + current match + case _: Token.Eof => + () + case Token.Newline(_) => + advance() + parseDirectives() + case Token.Using(usingPos) => + advance() + current match + case Token.Ident(keyText, keyPos) => + advance() + val values = parseValues() + val finalValues = + if values.isEmpty then + Seq(DirectiveValue.EmptyVal( + Position( + keyPos.line, + keyPos.column + keyText.length, + keyPos.offset + keyText.length + ) + )) + else values + directives += UsingDirective(keyText, finalValues, keyPos) + current match + case _: Token.Newline => advance() + case _ => () + parseDirectives() + + case Token.Newline(_) => + error("Expected a key after `using`", usingPos) + advance() + parseDirectives() + + case _: Token.Eof => + error("Expected a key after `using`", usingPos) + parseDirectives() + + case t => + error(s"Expected a key after `using`, found: ${tokenKindName(t)}", t.pos) + skipToNewline() + parseDirectives() + + case Token.LexError(msg, p) => + error(s"Lexer error: $msg", p) + skipToNewline() + parseDirectives() + + case t => + error(s"Unexpected token: ${tokenKindName(t)}", t.pos) + skipToNewline() + parseDirectives() + + parseDirectives() + (directives.toSeq, diagnostics.toSeq) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala new file mode 100644 index 0000000000..af079e2ca3 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Position.scala @@ -0,0 +1,4 @@ +package scala.cli.parse + +/** Position within a source file. Lines and columns are 0-based. */ +case class Position(line: Int, column: Int, offset: Int) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala new file mode 100644 index 0000000000..7b5ea0d341 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/Token.scala @@ -0,0 +1,42 @@ +package scala.cli.parse + +enum Token: + /** The `using` keyword. */ + case Using(pos: Position) + + /** A bare identifier or value (non-quoted, non-whitespace sequence). */ + case Ident(value: String, pos: Position) + + /** A double-quoted string literal. */ + case StringLit(value: String, pos: Position) + + /** Boolean literal `true` or `false`. */ + case BoolLit(value: Boolean, pos: Position) + + /** Dot separator in dotted keys. */ + case Dot(pos: Position) + + /** Comma – accepted as a deprecated value separator. */ + case Comma(pos: Position) + + /** End of a directive line. */ + case Newline(pos: Position) + + /** End of token stream. */ + case Eof(pos: Position) + + /** A lexer error. */ + case LexError(message: String, pos: Position) + +object Token: + extension (t: Token) + def pos: Position = t match + case Using(p) => p + case Ident(_, p) => p + case StringLit(_, p) => p + case BoolLit(_, p) => p + case Dot(p) => p + case Comma(p) => p + case Newline(p) => p + case Eof(p) => p + case LexError(_, p) => p diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala new file mode 100644 index 0000000000..0c09586862 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirective.scala @@ -0,0 +1,15 @@ +package scala.cli.parse + +/** A single parsed `//> using` directive. */ +case class UsingDirective( + key: String, + values: Seq[DirectiveValue], + keyPosition: Position +) + +/** The result of parsing a source file for using directives. */ +case class UsingDirectivesResult( + directives: Seq[UsingDirective], + codeOffset: Int, + diagnostics: Seq[UsingDirectiveDiagnostic] +) diff --git a/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala new file mode 100644 index 0000000000..f58407f800 --- /dev/null +++ b/modules/directives-parser/src/main/scala/scala/cli/parse/UsingDirectivesParser.scala @@ -0,0 +1,57 @@ +package scala.cli.parse + +/** Public entry point for the using directives parser. + * + * The pipeline is: + * 1. [[CommentExtractor.extract]] – scans the source and identifies `//> using` lines + * 2. [[Lexer.tokenize]] – tokenizes the content of each directive line + * 3. [[Parser.parse]] – converts the token stream into [[UsingDirective]] nodes + */ +object UsingDirectivesParser: + + /** Parse a source file and return all using directives along with diagnostics. + * + * @param content + * the raw content of the source file as a character array + * @return + * a [[UsingDirectivesResult]] with parsed directives, code offset, and diagnostics + */ + def parse(content: Array[Char]): UsingDirectivesResult = + val extracted = CommentExtractor.extract(content) + + val allTokens = scala.collection.mutable.ArrayBuffer.empty[Token] + for line <- extracted.directiveLines do + val tokens = Lexer.tokenize(line.content, line.lineNum, line.lineStartOffset) + allTokens ++= tokens + + allTokens += Token.Eof( + extracted.directiveLines.lastOption + .map(l => Position(l.lineNum, 0, l.lineStartOffset)) + .getOrElse(Position(0, 0, 0)) + ) + + val (directives, parserDiagnostics) = Parser.parse(allTokens.toSeq) + + val allDiagnostics = parserDiagnostics ++ extracted.diagnostics + + UsingDirectivesResult( + directives = directives, + codeOffset = extracted.codeOffset, + diagnostics = allDiagnostics + ) + + /** Tokenize a directive line. Exposed for testing. + * + * @param lineText + * the full line text including `//> ` prefix + * @param lineNum + * 0-indexed line number in the original file + * @param lineStartOffset + * absolute byte offset of the start of the line + */ + def tokenize(lineText: String, lineNum: Int, lineStartOffset: Int): Seq[Token] = + Lexer.tokenize(lineText, lineNum, lineStartOffset) + + /** Extract directive lines from source content. Exposed for testing. */ + def extractLines(content: Array[Char]): ExtractorResult = + CommentExtractor.extract(content) diff --git a/modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala b/modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala new file mode 100644 index 0000000000..a3a0c3d0f0 --- /dev/null +++ b/modules/directives-parser/src/test/scala/scala/cli/parse/CommentExtractorTests.scala @@ -0,0 +1,104 @@ +package scala.cli.parse + +import munit.FunSuite + +class CommentExtractorTests extends FunSuite: + + private def extract(src: String): ExtractorResult = + CommentExtractor.extract(src.toCharArray) + + test("simple directive line") { + val r = extract("//> using scala 3\n") + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 0) + assertEquals(r.codeOffset, 18) + } + + test("shebang is skipped on line 0") { + val src = "#!/usr/bin/env scala\n//> using scala 3\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.directiveLines.head.lineNum, 1) + } + + test("shebang after line 0 treated as code") { + val src = "//> using scala 3\n#!/usr/bin/env scala\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + assertEquals(r.codeOffset, 18) // shebang line is code + } + + test("blank lines do not affect directive region") { + val src = "\n//> using scala 3\n\nval x = 1\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("line comments are skipped") { + val src = "// a comment\n//> using scala 3\nval x = 1\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("block comment before directive is skipped") { + val src = "/* comment */\n//> using scala 3\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("multi-line block comment is skipped") { + val src = "/*\n * block\n */\n//> using scala 3\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 1) + } + + test("codeOffset points to start of first code line") { + val src = "//> using scala 3\nval x = 1\n" + val r = extract(src) + assertEquals(r.codeOffset, 18) // "//> using scala 3\n" is 18 chars + } + + test("no code -> codeOffset is file length") { + val src = "//> using scala 3\n" + val r = extract(src) + assertEquals(r.codeOffset, src.length) + } + + test("directive after code emits warning") { + val src = "val x = 1\n//> using scala 3\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assert(r.diagnostics.exists(_.severity == DiagnosticSeverity.Warning)) + } + + test("directive after code is NOT parsed") { + val src = "val x = 1\n//> using scala 3\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + } + + test("multiple directives") { + val src = "//> using scala 3\n//> using dep com.lihaoyi::os-lib:0.11.4\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 2) + } + + test("lineStartOffset is correct") { + val src = "//> using scala 3\n//> using dep foo\n" + val r = extract(src) + assertEquals(r.directiveLines(0).lineStartOffset, 0) + assertEquals(r.directiveLines(1).lineStartOffset, 18) + } + + test("directives inside block comment are ignored") { + val src = "/* //> using scala 3 */\nval x = 1\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + } + + test("`//> ` without `using` treated as code") { + val src = "//> notUsing foo\nval x = 1\n" + val r = extract(src) + assertEquals(r.directiveLines.length, 0) + assertEquals(r.codeOffset, 0) // first line is code + } diff --git a/modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala b/modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala new file mode 100644 index 0000000000..ac7d1f7d4c --- /dev/null +++ b/modules/directives-parser/src/test/scala/scala/cli/parse/LexerTests.scala @@ -0,0 +1,124 @@ +package scala.cli.parse + +import munit.FunSuite + +class LexerTests extends FunSuite: + + private def lex(line: String, lineNum: Int = 0): Seq[Token] = + Lexer.tokenize(line, lineNum, 0) + + private def lexTokens(line: String): Seq[Token] = + lex(line).dropRight(1) // drop trailing Newline + + test("tokenize `using` keyword") { + val tokens = lexTokens("//> using dep foo") + assert(tokens.exists { case _: Token.Using => true; case _ => false }) + } + + test("tokenize bare identifier") { + val tokens = lexTokens("//> using dep foo") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents, Seq("dep", "foo")) + } + + test("tokenize dotted key as single Ident") { + val tokens = lexTokens("//> using test.dep munit") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents, Seq("test.dep", "munit")) + } + + test("tokenize quoted string") { + val tokens = lexTokens("""//> using dep "com.lihaoyi::os-lib:0.11.4"""") + val strLits = tokens.collect { case Token.StringLit(v, _) => v } + assertEquals(strLits, Seq("com.lihaoyi::os-lib:0.11.4")) + } + + test("tokenize boolean true") { + val tokens = lexTokens("//> using publish.doc true") + assert(tokens.exists { case Token.BoolLit(true, _) => true; case _ => false }) + } + + test("tokenize boolean false") { + val tokens = lexTokens("//> using publish.doc false") + assert(tokens.exists { case Token.BoolLit(false, _) => true; case _ => false }) + } + + test("comma followed by whitespace is a Comma token") { + val tokens = lexTokens("//> using dep a, b") + assert(tokens.exists { case _: Token.Comma => true; case _ => false }) + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents.filter(_ != "dep"), Seq("a", "b")) + } + + test("comma embedded in value is NOT a separator") { + val tokens = lexTokens("//> using packaging.graalvmArgs --enable-url-protocols=http,https") + // the comma inside the value should be consumed as part of the Ident + val idents = tokens.collect { case Token.Ident(v, _) => v } + assert(idents.contains("--enable-url-protocols=http,https"), s"idents=$idents") + assert(!tokens.exists { case _: Token.Comma => true; case _ => false }) + } + + test("unterminated string literal produces LexError") { + val tokens = lexTokens("""//> using dep "unterminated""") + assert(tokens.exists { case _: Token.LexError => true; case _ => false }, s"tokens=$tokens") + } + + test("string escape sequences") { + val tokens = lexTokens("""//> using dep "line1\nline2"""") + val strLits = tokens.collect { case Token.StringLit(v, _) => v } + assertEquals(strLits, Seq("line1\nline2")) + } + + test("column position of key") { + // `//> using dep foo` — `dep` starts at column 10 + val tokens = lexTokens("//> using dep foo") + val depToken = tokens.collectFirst { case t @ Token.Ident("dep", _) => t } + assert(depToken.isDefined, "expected Ident(dep)") + assertEquals(depToken.get.pos.column, 10) + } + + test("column position of value") { + // `//> using dep foo` — `foo` starts at column 14 + val tokens = lexTokens("//> using dep foo") + val fooToken = tokens.collectFirst { case t @ Token.Ident("foo", _) => t } + assert(fooToken.isDefined) + assertEquals(fooToken.get.pos.column, 14) + } + + test("line position is preserved") { + val tokens = Lexer.tokenize("//> using dep foo", lineNum = 3, lineStartOffset = 100) + val depToken = tokens.collectFirst { case t @ Token.Ident("dep", _) => t } + assert(depToken.isDefined) + assertEquals(depToken.get.pos.line, 3) + } + + test("trailing Newline token is always emitted") { + val tokens = lex("//> using scala 3") + assert(tokens.last match { case _: Token.Newline => true; case _ => false }) + } + + test("empty directive line still produces Newline") { + val tokens = lex("//> using") + assert(tokens.last match { case _: Token.Newline => true; case _ => false }) + } + + test("`using` as first ident after `//> ` is a Using token, not Ident") { + val tokens = lexTokens("//> using scala 3") + val firstMeaningful = tokens.head + assert( + firstMeaningful match { case _: Token.Using => true; case _ => false }, + s"got $firstMeaningful" + ) + } + + test("backtick-quoted identifier strips backticks") { + val tokens = lexTokens("//> using `native-gc`") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents, Seq("native-gc")) + } + + test("backtick identifier with dash is a valid Ident") { + val tokens = lexTokens("//> using `native-mode`") + val idents = tokens.collect { case Token.Ident(v, _) => v } + assertEquals(idents, Seq("native-mode")) + } diff --git a/modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala b/modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala new file mode 100644 index 0000000000..6d472e26f5 --- /dev/null +++ b/modules/directives-parser/src/test/scala/scala/cli/parse/ParserTests.scala @@ -0,0 +1,218 @@ +package scala.cli.parse + +import munit.FunSuite + +class ParserTests extends FunSuite: + + private def parse(src: String): UsingDirectivesResult = + UsingDirectivesParser.parse(src.toCharArray) + + private def directives(src: String): Seq[UsingDirective] = + parse(src).directives + + private def warnings(src: String): Seq[UsingDirectiveDiagnostic] = + parse(src).diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + + // ----------------------------------------------------------------------- + // Basic parsing + // ----------------------------------------------------------------------- + + test("parse a simple directive") { + val ds = directives("//> using scala 3\n") + assertEquals(ds.length, 1) + assertEquals(ds.head.key, "scala") + assertEquals(ds.head.values.length, 1) + } + + test("parse key value") { + val ds = directives("//> using scala 3\n") + val v = ds.head.values.head + v match + case sv: DirectiveValue.StringVal => assertEquals(sv.value, "3") + case _ => fail(s"expected StringVal, got $v") + } + + test("parse dotted key") { + val ds = directives("//> using test.dep munit::munit:1.0.0\n") + assertEquals(ds.head.key, "test.dep") + } + + test("parse multiple values") { + val ds = directives("//> using dep com.lihaoyi::os-lib:0.11.4 com.lihaoyi::upickle:3.1.0\n") + val values = ds.head.values + assertEquals(values.length, 2) + } + + test("parse quoted string value") { + val ds = directives("""//> using scalacOption "-Xfatal-warnings"""" + "\n") + val v = ds.head.values.head + v match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "-Xfatal-warnings") + assert(sv.isQuoted) + case _ => fail(s"expected StringVal, got $v") + } + + test("parse boolean true") { + val ds = directives("//> using publish.doc true\n") + val v = ds.head.values.head + v match + case bv: DirectiveValue.BoolVal => assertEquals(bv.value, true) + case _ => fail(s"expected BoolVal, got $v") + } + + test("parse boolean false") { + val ds = directives("//> using publish.doc false\n") + val v = ds.head.values.head + v match + case bv: DirectiveValue.BoolVal => assertEquals(bv.value, false) + case _ => fail(s"expected BoolVal, got $v") + } + + test("directive with no values produces EmptyVal") { + val ds = directives("//> using toolkit\n") + assertEquals(ds.head.values.length, 1) + ds.head.values.head match + case _: DirectiveValue.EmptyVal => () + case v => fail(s"expected EmptyVal, got $v") + } + + test("multiple directives") { + val src = "//> using scala 3\n//> using dep foo\n" + val ds = directives(src) + assertEquals(ds.length, 2) + assertEquals(ds(0).key, "scala") + assertEquals(ds(1).key, "dep") + } + + // ----------------------------------------------------------------------- + // Comma deprecation + // ----------------------------------------------------------------------- + + test("comma separator emits deprecation warning") { + val ws = warnings("//> using dep a, b\n") + assert(ws.nonEmpty, s"expected warnings, got none") + assert(ws.exists(_.message.contains("deprecated"))) + } + + test("each comma as separator emits its own deprecation warning") { + val ws = warnings("//> using dep a, b, c\n") + val deprecationMsgs = ws.filter(_.message.contains("Use of commas as separators")) + assertEquals(deprecationMsgs.length, 2) + } + + test("comma separator still parses both values") { + val ds = directives("//> using dep a, b\n") + assertEquals(ds.head.values.length, 2) + } + + test("embedded comma (no space) does NOT emit warning") { + val ws = warnings("//> using packaging.graalvmArgs --enable-url-protocols=http,https\n") + assertEquals(ws.length, 0) + } + + // ----------------------------------------------------------------------- + // Position tracking + // ----------------------------------------------------------------------- + + test("key position line is correct") { + val ds = directives("//> using scala 3\n") + assertEquals(ds.head.keyPosition.line, 0) + } + + test("key position column is correct") { + // "//> using scala 3" + // 0123456789... + // `scala` starts at column 10 + val ds = directives("//> using scala 3\n") + assertEquals(ds.head.keyPosition.column, 10) + } + + test("value position column is correct") { + // "//> using scala 3" + // ^16 + val ds = directives("//> using scala 3\n") + val vPos = ds.head.values.head.pos + assertEquals(vPos.column, 16) + } + + test("key position offset is correct") { + val ds = directives("//> using scala 3\n") + val offset = ds.head.keyPosition.offset + assertEquals(offset, 10) // first line starts at 0, column 10 + } + + test("value position on second line has correct line number") { + val src = "//> using scala 3\n//> using dep foo\n" + val ds = directives(src) + assertEquals(ds(1).keyPosition.line, 1) + } + + test("value position on second line has correct offset") { + val src = "//> using scala 3\n//> using dep foo\n" + val ds = directives(src) + val depOffset = ds(1).keyPosition.offset + // "//> using scala 3\n" is 18 chars, then "//> using " is 10 more = offset 28 + assertEquals(depOffset, 28) + } + + // ----------------------------------------------------------------------- + // Diagnostics + // ----------------------------------------------------------------------- + + test("directive after code emits warning") { + val src = "val x = 1\n//> using scala 3\n" + val ws = warnings(src) + assert(ws.nonEmpty) + } + + test("directive after code is not included in results") { + val src = "val x = 1\n//> using scala 3\n" + val ds = directives(src) + assertEquals(ds.length, 0) + } + + // ----------------------------------------------------------------------- + // codeOffset + // ----------------------------------------------------------------------- + + test("codeOffset after single directive") { + val src = "//> using scala 3\nval x = 1\n" + val r = parse(src) + assertEquals(r.codeOffset, 18) + } + + test("codeOffset with no directives is 0") { + val src = "val x = 1\n" + val r = parse(src) + assertEquals(r.codeOffset, 0) + } + + test("codeOffset at end of file when only directives") { + val src = "//> using scala 3\n" + val r = parse(src) + assertEquals(r.codeOffset, src.length) + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + test("empty source") { + val r = parse("") + assertEquals(r.directives.length, 0) + assertEquals(r.codeOffset, 0) + } + + test("source with only blank lines") { + val r = parse("\n\n\n") + assertEquals(r.directives.length, 0) + } + + test("value containing colon") { + val ds = directives("//> using dep com.lihaoyi::os-lib:0.11.4\n") + ds.head.values.head match + case sv: DirectiveValue.StringVal => + assertEquals(sv.value, "com.lihaoyi::os-lib:0.11.4") + case v => fail(s"expected StringVal, got $v") + } diff --git a/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala b/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala index f314fdf69f..3440a5b54e 100644 --- a/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala +++ b/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala @@ -1,7 +1,5 @@ package scala.build.directives -import com.virtuslab.using_directives.custom.model.{BooleanValue, EmptyValue, StringValue, Value} - import scala.build.errors.{ BuildException, CompositeBuildException, @@ -13,11 +11,12 @@ import scala.build.errors.{ import scala.build.preprocessing.ScopePath import scala.build.preprocessing.directives.DirectiveUtil import scala.build.{Position, Positioned} +import scala.cli.parse.DirectiveValue abstract class DirectiveValueParser[+T] { def parse( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], scopePath: ScopePath, path: Either[String, os.Path] ): Either[BuildException, T] @@ -32,7 +31,7 @@ object DirectiveValueParser { extends DirectiveValueParser[U] { def parse( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], scopePath: ScopePath, path: Either[String, os.Path] ): Either[BuildException, U] = @@ -42,14 +41,14 @@ object DirectiveValueParser { abstract class DirectiveSingleValueParser[+T] extends DirectiveValueParser[T] { def parseValue( key: String, - value: Value[?], + value: DirectiveValue, cwd: ScopePath, path: Either[String, os.Path] ): Either[BuildException, T] final def parse( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], scopePath: ScopePath, path: Either[String, os.Path] ): Either[BuildException, T] = @@ -79,33 +78,36 @@ object DirectiveValueParser { } } - extension (value: Value[?]) { + extension (value: DirectiveValue) { def isEmpty: Boolean = value match { - case _: EmptyValue => true - case _ => false + case _: DirectiveValue.EmptyVal => true + case _ => false } def isString: Boolean = value match { - case _: StringValue => true - case _ => false + case _: DirectiveValue.StringVal => true + case _ => false } + def asString: Option[String] = value match { - case s: StringValue => Some(s.get()) - case _ => None + case s: DirectiveValue.StringVal => Some(s.value) + case _ => None } + def isBoolean: Boolean = value match { - case _: BooleanValue => true - case _ => false + case _: DirectiveValue.BoolVal => true + case _ => false } + def asBoolean: Option[Boolean] = value match { - case s: BooleanValue => Some(s.get()) - case _ => None + case b: DirectiveValue.BoolVal => Some(b.value) + case _ => None } def position(path: Either[String, os.Path]): Position = @@ -127,7 +129,7 @@ object DirectiveValueParser { case values0 => Left( new MalformedDirectiveError( - s"Unexpected values ${values0.map(_.toString).mkString(", ")}", + s"Unexpected values ${values0.map(_.rawText).mkString(", ")}", values0.map(_.position(path)) ) ) @@ -141,7 +143,7 @@ object DirectiveValueParser { new MalformedDirectiveError( message = s"""Encountered an error for the $key using directive. - |Expected a string, got '${value.getRelatedASTNode.toString}'""".stripMargin, + |Expected a string, got '${value.rawText}'""".stripMargin, positions = Seq(pos) ) }.map(DirectiveSpecialSyntax.handlingSpecialPathSyntax(_, path)) @@ -154,7 +156,7 @@ object DirectiveValueParser { val pos = value.position(path) new MalformedDirectiveError( s"""Encountered an error for the $key using directive. - |Expected a string value, got '${value.getRelatedASTNode.toString}'""".stripMargin, + |Expected a string value, got '${value.rawText}'""".stripMargin, Seq(pos) ) } diff --git a/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala b/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala index 990d23008f..f9be7f36b2 100644 --- a/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala +++ b/modules/directives/src/main/scala/scala/build/directives/ScopedValue.scala @@ -1,11 +1,9 @@ package scala.build.preprocessing.directives -import com.virtuslab.using_directives.custom.model.Value - import scala.build.Positioned import scala.build.preprocessing.ScopePath -case class ScopedValue[T <: Value[?]]( +case class ScopedValue[T]( positioned: Positioned[String], maybeScopePath: Option[ScopePath] = None ) diff --git a/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala b/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala index 8f9ffb046c..87af626d48 100644 --- a/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala +++ b/modules/directives/src/main/scala/scala/build/errors/SingleValueExpectedError.scala @@ -7,7 +7,7 @@ final class SingleValueExpectedError( val path: Either[String, os.Path] ) extends BuildException( s"Expected a single value for directive ${directive.key} " + - s"(got ${directive.values.length} values: ${directive.values.map(_.get().toString).mkString(", ")})", + s"(got ${directive.values.length} values: ${directive.values.map(_.stringValue).mkString(", ")})", positions = DirectiveUtil.positions(directive.values, path) ) { assert(directive.stringValuesCount > 1) diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala index 30faecea7f..1e2067561e 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala @@ -2,10 +2,8 @@ package scala.build.preprocessing.directives import java.util.Locale import scala.build.Logger -import scala.build.Ops.* import scala.build.directives.* -import scala.build.errors.{BuildException, CompositeBuildException, UnexpectedDirectiveError} -import scala.build.preprocessing.Scoped +import scala.build.errors.{BuildException, UnexpectedDirectiveError} import scala.cli.commands.SpecificationLevel import scala.quoted.* @@ -288,46 +286,21 @@ object DirectiveHandler { (scopedDirective, logger) => '{ - if (${ cond('{ $scopedDirective.directive.key }) }) { - val valuesByScope = $scopedDirective.directive.values.groupBy(_.getScope) - .toVector - .map { - case (scopeOrNull, values) => - (Option(scopeOrNull), values) - } - .sortBy(_._1.getOrElse("")) - valuesByScope - .map { - case (scopeOpt, _) => - $parser.parse( - $scopedDirective.directive.key, - $scopedDirective.directive.values, - $scopedDirective.cwd, - $scopedDirective.maybePath - ).map { r => - scopeOpt -> ${ - genNew(List(newArgs.updated(idx, 'r).map(_.asTerm))) - .asExprOf[T] - } - } - } - .sequence - .left.map(CompositeBuildException(_)) - .map { v => - val mainOpt = v.collectFirst { - case (None, t) => t - } - val scoped = v.collect { - case (Some(scopeStr), t) => - // FIXME os.RelPath(…) might fail - Scoped( - $scopedDirective.cwd / os.RelPath(scopeStr), - t - ) - } - ProcessedDirective(mainOpt, scoped) - } - } + if (${ cond('{ $scopedDirective.directive.key }) }) + $parser.parse( + $scopedDirective.directive.key, + $scopedDirective.directive.values, + $scopedDirective.cwd, + $scopedDirective.maybePath + ).map { r => + ProcessedDirective( + Some(${ + genNew(List(newArgs.updated(idx, 'r).map(_.asTerm))) + .asExprOf[T] + }), + Nil + ) + } else ${ elseCase0(scopedDirective, logger) } } diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala index 5380238d4a..5470434c62 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveUtil.scala @@ -1,48 +1,36 @@ package scala.build.preprocessing.directives -import com.virtuslab.using_directives.custom.model.{BooleanValue, StringValue, Value} -import com.virtuslab.using_directives.custom.utils.ast.StringLiteral import dependency.AnyDependency import dependency.parser.DependencyParser import scala.build.Ops.* import scala.build.errors.{BuildException, CompositeBuildException, DependencyFormatError} -import scala.build.preprocessing.ScopePath import scala.build.{Position, Positioned} +import scala.cli.parse.DirectiveValue object DirectiveUtil { - def isWrappedInDoubleQuotes(v: Value[?]): Boolean = - v match { - case stringValue: StringValue => - stringValue.getRelatedASTNode match { - case literal: StringLiteral => literal.getIsWrappedDoubleQuotes() - case _ => false - } - case _ => false - } - def position(v: Value[?], path: Either[String, os.Path]): Position.File = { - val skipQuotes: Boolean = isWrappedInDoubleQuotes(v) - val line = v.getRelatedASTNode.getPosition.getLine - val column = v.getRelatedASTNode.getPosition.getColumn + (if (skipQuotes) 1 else 0) - val endLinePos = column + v.toString.length - Position.File(path, (line, column), (line, endLinePos)) - } + def isWrappedInDoubleQuotes(v: DirectiveValue): Boolean = + v.isQuotedString - def scope(v: Value[?], cwd: ScopePath): Option[ScopePath] = - Option(v.getScope).map((p: String) => cwd / os.RelPath(p)) + def position(v: DirectiveValue, path: Either[String, os.Path]): Position.File = { + val p = v.pos + val skipQuotes = v.isQuotedString + val column = p.column + (if skipQuotes then 1 else 0) + val endCol = column + v.stringValue.length + Position.File(path, (p.line, column), (p.line, endCol)) + } def concatAllValues( scopedDirective: ScopedDirective ): Seq[String] = scopedDirective.directive.values.collect: - case v: StringValue => v.get - case v: BooleanValue => v.get.toString + case v: DirectiveValue.StringVal => v.value + case v: DirectiveValue.BoolVal => v.value.toString - def positions(values: Seq[Value[?]], path: Either[String, os.Path]): Seq[Position] = + def positions(values: Seq[DirectiveValue], path: Either[String, os.Path]): Seq[Position] = values.map { v => - val line = v.getRelatedASTNode.getPosition.getLine - val column = v.getRelatedASTNode.getPosition.getColumn - Position.File(path, (line, column), (line, column)) + val p = v.pos + Position.File(path, (p.line, p.column), (p.line, p.column)) } extension (deps: List[Positioned[String]]) { diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala index 4dcc8990c3..9ea816dd56 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala @@ -1,7 +1,6 @@ package scala.build.preprocessing.directives import scala.build.EitherCps.either -import scala.build.Ops.* import scala.build.Positioned import scala.build.directives.* import scala.build.errors.BuildException diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala index e3eff4a320..4c93df238b 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/StrictDirective.scala @@ -1,8 +1,7 @@ package scala.build.preprocessing.directives -import com.virtuslab.using_directives.custom.model.{EmptyValue, Value} - import scala.build.Position +import scala.cli.parse.DirectiveValue /** Represents a directive with a key and a sequence of values. * @@ -12,22 +11,28 @@ import scala.build.Position * the sequence of values of the directive * @param startColumn * the column where the key of the directive starts + * @param startLine + * the line where the key of the directive starts */ - case class StrictDirective( key: String, - values: Seq[Value[?]], + values: Seq[DirectiveValue], startColumn: Int = 0, startLine: Int = 0 ) { + + /** Same style as the legacy `using_directives` values' `toString` (unquoted string content), so + * user-facing messages (e.g. experimental-feature warnings) match integration-test expectations. + */ override def toString: String = { - val suffix = if validValues.isEmpty then "" else s" ${validValues.mkString(" ")}" + val suffix = + if validValues.isEmpty then "" else s" ${validValues.map(_.stringValue).mkString(" ")}" s"//> using $key$suffix" } - private def validValues = values.filter { - case _: EmptyValue => false - case _ => true + private def validValues: Seq[DirectiveValue] = values.filter { + case _: DirectiveValue.EmptyVal => false + case _ => true } /** Checks whether the directive with the sequence of values will fit into the given column limit, @@ -36,20 +41,16 @@ case class StrictDirective( * distinct values, each with a single value. */ def explodeToStringsWithColLimit(colLimit: Int = 100): Seq[String] = { - val validValues = values.filter { - case _: EmptyValue => false - case _ => true - } - + val validVals = validValues val usingKeyString = s"//> using $key" - if (validValues.isEmpty) + if (validVals.isEmpty) Seq(usingKeyString) else { - val distinctValuesStrings = validValues - .map { - case s if s.toString.exists(_.isWhitespace) => s"\"$s\"" - case s => s.toString + val distinctValuesStrings = validVals + .map { v => + val s = v.stringValue + if s.exists(_.isWhitespace) then s"\"$s\"" else s } .distinct .sorted @@ -63,17 +64,18 @@ case class StrictDirective( def stringValuesCount: Int = validValues.length - def toStringValues: Seq[String] = validValues.map(_.toString) + def toStringValues: Seq[String] = validValues.map(_.stringValue) def position(path: Either[String, os.Path]): Position.File = values.lastOption .map { v => - val position = DirectiveUtil.position(v, path) + val p = v.pos v match - case _: EmptyValue => position.startPos - case v if DirectiveUtil.isWrappedInDoubleQuotes(v) => - position.endPos._1 -> (position.endPos._2 + 1) - case _ => position.endPos + case _: DirectiveValue.EmptyVal => (p.line, p.column) + case bv: DirectiveValue.BoolVal => (p.line, p.column + bv.value.toString.length) + case sv: DirectiveValue.StringVal if sv.isQuoted => + (p.line, p.column + sv.value.length + 2) + case sv: DirectiveValue.StringVal => (p.line, p.column + sv.value.length) }.map { (line, endColumn) => Position.File( path, @@ -81,5 +83,4 @@ case class StrictDirective( (line, endColumn) ) }.getOrElse(Position.File(path, (0, 0), (0, 0))) - } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala b/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala index 0e70156967..304d9f1923 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PublishSetupTests.scala @@ -1,12 +1,10 @@ package scala.cli.integration import com.eed3si9n.expecty.Expecty.expect -import com.virtuslab.using_directives.UsingDirectivesProcessor -import com.virtuslab.using_directives.reporter.ConsoleReporter import org.eclipse.jgit.api.Git import org.eclipse.jgit.transport.URIish -import scala.jdk.CollectionConverters.* +import scala.cli.parse.{DirectiveValue, UsingDirectivesParser} import scala.util.Properties import scala.util.matching.Regex @@ -70,21 +68,13 @@ class PublishSetupTests extends ScalaCliSuite { } private def directives(content: String): Map[String, Seq[String]] = { - val reporter = new ConsoleReporter - val processor = new UsingDirectivesProcessor(reporter) - - val usedDirectives = processor - .extract(content.toCharArray) - .asScala - .head - - usedDirectives - .getFlattenedMap - .asScala - .toSeq - .map { - case (k, l) => - (k.getPath.asScala.mkString("."), l.asScala.toSeq.map(_.toString)) + val result = UsingDirectivesParser.parse(content.toCharArray) + result.directives + .map { d => + d.key -> d.values.collect { + case DirectiveValue.StringVal(v, _, _) => v + case DirectiveValue.BoolVal(v, _) => v.toString + } } .toMap } diff --git a/project/deps/package.mill b/project/deps/package.mill index 3d21cca463..bcc3895a42 100644 --- a/project/deps/package.mill +++ b/project/deps/package.mill @@ -281,7 +281,6 @@ object Deps { def toolkitTest = mvn"org.scala-lang:toolkit-test:$toolkitVersion" val typelevelToolkitVersion = "0.1.29" def typelevelToolkit = mvn"org.typelevel:toolkit:$typelevelToolkitVersion" - def usingDirectives = mvn"org.virtuslab:using_directives:1.1.4" // Lives at https://github.com/VirtusLab/no-crc32-zip-input-stream, see #865 // This provides a ZipInputStream that doesn't verify CRC32 checksums, that users // can enable by setting SCALA_CLI_VENDORED_ZIS=true in the environment, to workaround