Skip to content

Commit d1e9eb3

Browse files
authored
Add support for ```java snippets in Markdown inputs (#4284)
1 parent c452e9d commit d1e9eb3

13 files changed

Lines changed: 682 additions & 30 deletions

File tree

modules/build/src/main/scala/scala/build/Sources.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ object Sources {
120120
): Seq[Preprocessor] =
121121
Seq(
122122
ScriptPreprocessor,
123-
MarkdownPreprocessor,
123+
MarkdownPreprocessor(archiveCache, javaClassNameVersionOpt, javaCommand),
124124
JavaPreprocessor(archiveCache, javaClassNameVersionOpt, javaCommand),
125125
ScalaPreprocessor,
126126
DataPreprocessor,

modules/build/src/main/scala/scala/build/internal/markdown/MarkdownCodeBlock.scala

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,39 @@ case class MarkdownCodeBlock(
2222
endLine: Int
2323
) {
2424

25+
private def supportedLanguage: Boolean =
26+
info.headOption.exists(lang => lang == "scala" || lang == "java")
27+
2528
/** @return
2629
* `true` if this snippet should be ignored, `false` otherwise
2730
*/
28-
def shouldIgnore: Boolean = info.head != "scala" || info.contains("ignore")
31+
def shouldIgnore: Boolean = !supportedLanguage || info.contains("ignore")
32+
33+
/** @return
34+
* `true` if this snippet is a Scala snippet, `false` otherwise
35+
*/
36+
def isScala: Boolean = info.headOption.contains("scala")
37+
38+
/** @return
39+
* `true` if this snippet is a Java snippet, `false` otherwise
40+
*/
41+
def isJava: Boolean = info.headOption.contains("java")
2942

3043
/** @return
3144
* `true` if this snippet should have its scope reset, `false` otherwise
3245
*/
33-
def resetScope: Boolean = info.contains("reset")
46+
def resetScope: Boolean = isScala && info.contains("reset")
3447

3548
/** @return
3649
* `true` if this snippet is a test snippet, `false` otherwise
3750
*/
3851
def isTest: Boolean = info.contains("test")
3952

4053
/** @return
41-
* `true` if this snippet is a raw snippet, `false` otherwise
54+
* `true` if this snippet is a raw snippet, `false` otherwise. Only meaningful for Scala
55+
* snippets; Java snippets are always emitted as raw `.java` sources.
4256
*/
43-
def isRaw: Boolean = info.contains("raw")
57+
def isRaw: Boolean = isScala && info.contains("raw")
4458
}
4559

4660
object MarkdownCodeBlock {

modules/build/src/main/scala/scala/build/preprocessing/MarkdownCodeBlockProcessor.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ object MarkdownCodeBlockProcessor {
1414
logger: Logger,
1515
maybeRecoverOnError: BuildException => Option[BuildException]
1616
): Either[BuildException, PreprocessedMarkdown] = either {
17-
val (rawCodeBlocks, remaining) = codeBlocks.partition(_.isRaw)
17+
val (javaBlocks, scalaBlocks) = codeBlocks.partition(_.isJava)
18+
val (javaTestBlocks, javaMainBlocks) = javaBlocks.partition(_.isTest)
19+
val (rawCodeBlocks, remaining) = scalaBlocks.partition(_.isRaw)
1820
val (testCodeBlocks, scriptCodeBlocks) = remaining.partition(_.isTest)
1921
def preprocessCodeBlocks(cbs: Seq[MarkdownCodeBlock])
2022
: Either[BuildException, PreprocessedMarkdownCodeBlocks] = either {
@@ -39,7 +41,9 @@ object MarkdownCodeBlockProcessor {
3941
PreprocessedMarkdown(
4042
value(preprocessCodeBlocks(scriptCodeBlocks)),
4143
value(preprocessCodeBlocks(rawCodeBlocks)),
42-
value(preprocessCodeBlocks(testCodeBlocks))
44+
value(preprocessCodeBlocks(testCodeBlocks)),
45+
value(preprocessCodeBlocks(javaMainBlocks)),
46+
value(preprocessCodeBlocks(javaTestBlocks))
4347
)
4448
}
4549
}

modules/build/src/main/scala/scala/build/preprocessing/MarkdownPreprocessor.scala

Lines changed: 132 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
11
package scala.build.preprocessing
22

3+
import coursier.cache.ArchiveCache
4+
import coursier.util.Task
5+
36
import java.nio.charset.StandardCharsets
47

58
import scala.build.EitherCps.{either, value}
69
import scala.build.Logger
710
import scala.build.errors.BuildException
811
import scala.build.input.{MarkdownFile, ScalaCliInvokeData, SingleElement, VirtualMarkdownFile}
12+
import scala.build.internal.JavaParserProxyMaker
913
import scala.build.internal.markdown.{MarkdownCodeBlock, MarkdownCodeWrapper}
1014
import scala.build.options.SuppressWarningOptions
1115
import scala.build.preprocessing.ScalaPreprocessor.ProcessingOutput
16+
import scala.build.preprocessing.directives.PreprocessedDirectives
1217

13-
case object MarkdownPreprocessor extends Preprocessor {
18+
/** Markdown source preprocessor.
19+
*
20+
* @param archiveCache
21+
* when using a java-class-name external binary to infer a class name (see [[JavaParserProxy]]),
22+
* a cache to download that binary with
23+
* @param javaClassNameVersionOpt
24+
* when using a java-class-name external binary to infer a class name (see [[JavaParserProxy]]),
25+
* this forces the java-class-name version to download
26+
*/
27+
final case class MarkdownPreprocessor(
28+
archiveCache: ArchiveCache[Task],
29+
javaClassNameVersionOpt: Option[String],
30+
javaCommand: () => String
31+
) extends Preprocessor {
1432
def preprocess(
1533
input: SingleElement,
1634
logger: Logger,
@@ -23,15 +41,18 @@ case object MarkdownPreprocessor extends Preprocessor {
2341
val res = either {
2442
val content = value(PreprocessingUtil.maybeRead(markdown.path))
2543
val preprocessed = value {
26-
MarkdownPreprocessor.preprocess(
27-
Right(markdown.path),
28-
content,
29-
markdown.subPath,
30-
ScopePath.fromPath(markdown.path),
31-
logger,
32-
maybeRecoverOnError,
33-
allowRestrictedFeatures,
34-
suppressWarningOptions
44+
preprocessContent(
45+
archiveCache = archiveCache,
46+
javaClassNameVersionOpt = javaClassNameVersionOpt,
47+
javaCommand = javaCommand,
48+
reportingPath = Right(markdown.path),
49+
content = content,
50+
subPath = markdown.subPath,
51+
scopePath = ScopePath.fromPath(markdown.path),
52+
logger = logger,
53+
maybeRecoverOnError = maybeRecoverOnError,
54+
allowRestrictedFeatures = allowRestrictedFeatures,
55+
suppressWarningOptions = suppressWarningOptions
3556
)
3657
}
3758
preprocessed
@@ -41,15 +62,18 @@ case object MarkdownPreprocessor extends Preprocessor {
4162
val content = new String(markdown.content, StandardCharsets.UTF_8)
4263
val res = either {
4364
val preprocessed = value {
44-
MarkdownPreprocessor.preprocess(
45-
Left(markdown.source),
46-
content,
47-
markdown.wrapperPath,
48-
markdown.scopePath,
49-
logger,
50-
maybeRecoverOnError,
51-
allowRestrictedFeatures,
52-
suppressWarningOptions
65+
preprocessContent(
66+
archiveCache = archiveCache,
67+
javaClassNameVersionOpt = javaClassNameVersionOpt,
68+
javaCommand = javaCommand,
69+
reportingPath = Left(markdown.source),
70+
content = content,
71+
subPath = markdown.wrapperPath,
72+
scopePath = markdown.scopePath,
73+
logger = logger,
74+
maybeRecoverOnError = maybeRecoverOnError,
75+
allowRestrictedFeatures = allowRestrictedFeatures,
76+
suppressWarningOptions = suppressWarningOptions
5377
)
5478
}
5579
preprocessed
@@ -59,7 +83,10 @@ case object MarkdownPreprocessor extends Preprocessor {
5983
None
6084
}
6185

62-
private def preprocess(
86+
private def preprocessContent(
87+
archiveCache: ArchiveCache[Task],
88+
javaClassNameVersionOpt: Option[String],
89+
javaCommand: () => String,
6390
reportingPath: Either[String, os.Path],
6491
content: String,
6592
subPath: os.SubPath,
@@ -106,6 +133,38 @@ case object MarkdownPreprocessor extends Preprocessor {
106133
}
107134
}
108135

136+
def emitJavaSnippets(
137+
blocks: PreprocessedMarkdownCodeBlocks,
138+
isTest: Boolean
139+
): Either[BuildException, List[PreprocessedSource.InMemory]] =
140+
either {
141+
val javaParser =
142+
(new JavaParserProxyMaker)
143+
.get(
144+
archiveCache,
145+
javaClassNameVersionOpt,
146+
logger,
147+
() => javaCommand()
148+
)
149+
blocks.codeBlocks.zipWithIndex.map { (block, index) =>
150+
value {
151+
emitJavaSnippet(
152+
block = block,
153+
index = index,
154+
isTest = isTest,
155+
subPath = subPath,
156+
scopePath = scopePath,
157+
reportingPath = reportingPath,
158+
javaParser = javaParser,
159+
logger = logger,
160+
allowRestrictedFeatures = allowRestrictedFeatures,
161+
suppressWarningOptions = suppressWarningOptions,
162+
maybeRecoverOnError = maybeRecoverOnError
163+
)
164+
}
165+
}.toList
166+
}
167+
109168
val codeBlocks: Seq[MarkdownCodeBlock] =
110169
value(MarkdownCodeBlock.findCodeBlocks(subPath, content, maybeRecoverOnError))
111170
val preprocessedMarkdown: PreprocessedMarkdown =
@@ -123,8 +182,60 @@ case object MarkdownPreprocessor extends Preprocessor {
123182
val maybeMainFile = value(preprocessSnippets(mainScalaCode, ".scala"))
124183
val maybeRawFile = value(preprocessSnippets(rawScalaCode, ".raw.scala"))
125184
val maybeTestFile = value(preprocessSnippets(testScalaCode, ".test.scala"))
185+
val javaFiles = value(emitJavaSnippets(preprocessedMarkdown.javaCodeBlocks, isTest = false))
186+
val javaTestFiles =
187+
value(emitJavaSnippets(preprocessedMarkdown.javaTestCodeBlocks, isTest = true))
126188

127-
maybeMainFile.toList ++ maybeTestFile ++ maybeRawFile
189+
maybeMainFile.toList ++ maybeTestFile ++ maybeRawFile ++ javaFiles ++ javaTestFiles
128190
}
129191

192+
private def emitJavaSnippet(
193+
block: MarkdownCodeBlock,
194+
index: Int,
195+
isTest: Boolean,
196+
subPath: os.SubPath,
197+
scopePath: ScopePath,
198+
reportingPath: Either[String, os.Path],
199+
javaParser: scala.build.internal.JavaParserProxy,
200+
logger: Logger,
201+
allowRestrictedFeatures: Boolean,
202+
suppressWarningOptions: SuppressWarningOptions,
203+
maybeRecoverOnError: BuildException => Option[BuildException]
204+
)(using ScalaCliInvokeData): Either[BuildException, PreprocessedSource.InMemory] = either {
205+
val classNameOpt = value {
206+
javaParser.className(block.body.getBytes(StandardCharsets.UTF_8))
207+
}
208+
val mdBaseName = subPath.last.stripSuffix(".md")
209+
val baseName = classNameOpt.getOrElse(s"${mdBaseName}_md_snippet$index")
210+
val javaFileName =
211+
if isTest then s"$baseName.test.java"
212+
else s"$baseName.java"
213+
val generatedRelPath =
214+
if isTest then os.rel / (subPath / os.up) / s"$baseName.java"
215+
else os.rel / (subPath / os.up) / javaFileName
216+
val snippetScopePath = scopePath.copy(subPath = (subPath / os.up) / javaFileName)
217+
val preprocessedDirectives: PreprocessedDirectives = value {
218+
DirectivesPreprocessor(
219+
reportingPath,
220+
snippetScopePath,
221+
logger,
222+
allowRestrictedFeatures,
223+
suppressWarningOptions,
224+
maybeRecoverOnError
225+
).preprocess(block.body)
226+
}
227+
PreprocessedSource.InMemory(
228+
originalPath = reportingPath.map(subPath -> _),
229+
relPath = generatedRelPath,
230+
content = block.body.getBytes(StandardCharsets.UTF_8),
231+
wrapperParamsOpt = None,
232+
options = Some(preprocessedDirectives.globalUsings),
233+
optionsWithTargetRequirements = preprocessedDirectives.usingsWithReqs,
234+
requirements = Some(preprocessedDirectives.globalReqs),
235+
scopedRequirements = preprocessedDirectives.scopedReqs,
236+
mainClassOpt = None,
237+
scopePath = snippetScopePath,
238+
directivesPositions = preprocessedDirectives.directivesPositions
239+
)
240+
}
130241
}

modules/build/src/main/scala/scala/build/preprocessing/PreprocessedMarkdown.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ object PreprocessedMarkdownCodeBlocks {
1515
case class PreprocessedMarkdown(
1616
scriptCodeBlocks: PreprocessedMarkdownCodeBlocks = PreprocessedMarkdownCodeBlocks.empty,
1717
rawCodeBlocks: PreprocessedMarkdownCodeBlocks = PreprocessedMarkdownCodeBlocks.empty,
18-
testCodeBlocks: PreprocessedMarkdownCodeBlocks = PreprocessedMarkdownCodeBlocks.empty
18+
testCodeBlocks: PreprocessedMarkdownCodeBlocks = PreprocessedMarkdownCodeBlocks.empty,
19+
javaCodeBlocks: PreprocessedMarkdownCodeBlocks = PreprocessedMarkdownCodeBlocks.empty,
20+
javaTestCodeBlocks: PreprocessedMarkdownCodeBlocks = PreprocessedMarkdownCodeBlocks.empty
1921
)
2022

2123
object PreprocessedMarkdown {

modules/build/src/test/scala/scala/build/tests/PreprocessingTests.scala

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
package scala.build.tests
22

33
import com.eed3si9n.expecty.Expecty.expect
4+
import coursier.cache.Cache.Fetch
5+
import coursier.cache.{ArchiveCache, ArtifactError, Cache}
6+
import coursier.util.{Artifact, EitherT, Task}
47

8+
import java.io.File
9+
10+
import scala.build.Sources
511
import scala.build.input.{MarkdownFile, ScalaCliInvokeData, Script, SourceScalaFile}
612
import scala.build.options.SuppressWarningOptions
713
import scala.build.preprocessing.{MarkdownPreprocessor, ScalaPreprocessor, ScriptPreprocessor}
14+
import scala.concurrent.ExecutionContext
815

916
class PreprocessingTests extends TestUtil.ScalaCliBuildSuite {
17+
private val markdownPreprocessor: MarkdownPreprocessor =
18+
Sources.defaultPreprocessors(
19+
ArchiveCache().withCache(
20+
new Cache[Task] {
21+
def fetch: Fetch[Task] = _ => sys.error("shouldn't be used")
22+
def file(artifact: Artifact): EitherT[Task, ArtifactError, File] =
23+
sys.error("shouldn't be used")
24+
def ec: ExecutionContext = sys.error("shouldn't be used")
25+
}
26+
),
27+
javaClassNameVersionOpt = None,
28+
javaCommand = () => sys.error("shouldn't be used")
29+
).collectFirst { case m: MarkdownPreprocessor => m }.get
1030
test("Report error if scala file not exists") {
1131
val logger = TestLogger()
1232
val scalaFile = SourceScalaFile(os.temp.dir(), os.SubPath("NotExists.scala"))
@@ -45,7 +65,7 @@ class PreprocessingTests extends TestUtil.ScalaCliBuildSuite {
4565
val logger = TestLogger()
4666
val markdownFile = MarkdownFile(os.temp.dir(), os.SubPath("NotExists.md"))
4767

48-
val res = MarkdownPreprocessor.preprocess(
68+
val res = markdownPreprocessor.preprocess(
4969
markdownFile,
5070
logger,
5171
allowRestrictedFeatures = false,

0 commit comments

Comments
 (0)