Skip to content

Commit b801961

Browse files
authored
Merge pull request #868 from softwaremill/scala-cli-single-file-mode
Scala cli single file mode
2 parents dd991f5 + 6fbcacf commit b801961

File tree

5 files changed

+150
-60
lines changed

5 files changed

+150
-60
lines changed

backend/src/main/scala/com/softwaremill/adopttapir/template/ProjectGenerator.scala

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ object ProjectGenerator:
1515
def generate(starterDetails: StarterDetails): List[GeneratedFile] =
1616
starterDetails.builder match
1717
case Builder.Sbt => SbtProjectTemplate.generate(starterDetails)
18-
case Builder.ScalaCli => ScalaCliProjectTemplate.generate(starterDetails)
18+
case Builder.ScalaCli => ScalaCliSingleFileTemplate.generate(starterDetails)
1919

2020
/** Twirl library was chosen for templating. Due to limitations in Twirl, some of arguments are passed as [[String]].<br> More advanced
2121
* rendering is done by dedicated objects `*View` e.g. @see [[EndpointsView]] or @[[MainView]].
@@ -208,28 +208,56 @@ object CommonObjectTemplate:
208208

209209
end CommonObjectTemplate
210210

211-
private object ScalaCliProjectTemplate extends ProjectTemplate:
212-
override def generate(starterDetails: StarterDetails): List[GeneratedFile] =
213-
super.generate(starterDetails) ::: List(getProjectScalaCli(starterDetails), readme, gitignore)
211+
private object ScalaCliSingleFileTemplate:
212+
def generate(starterDetails: StarterDetails): List[GeneratedFile] =
213+
List(getSingleFile(starterDetails))
214+
215+
private def getSingleFile(starterDetails: StarterDetails): GeneratedFile = {
216+
import CommonObjectTemplate.StarterDetailsWithLegalizedGroupId
217+
val groupId = starterDetails.legalizedGroupId
218+
219+
val dependencies = (BuildScalaCliView.getMainDependencies _).andThen(deps => BuildScalaCliView.format(deps, false))(starterDetails)
214220

215-
private def getProjectScalaCli(starterDetails: StarterDetails): GeneratedFile = {
216-
val dependencies = (BuildScalaCliView.getMainDependencies _).andThen(deps => BuildScalaCliView.format(deps, false))(starterDetails) +
217-
(BuildScalaCliView.getAllTestDependencies _).andThen(deps => BuildScalaCliView.format(deps, true))(starterDetails)
221+
val helloServerEndpoint = EndpointsView.getHelloServerEndpoint(starterDetails)
222+
val jsonEndpoint = EndpointsView.getJsonOutModel(starterDetails)
223+
val library = EndpointsView.getJsonLibrary(starterDetails)
224+
val apiEndpoints = EndpointsView.getApiEndpoints(starterDetails)
225+
val docEndpoints = EndpointsView.getDocEndpoints(starterDetails)
226+
val metricsEndpoint = EndpointsView.getMetricsEndpoint(starterDetails)
227+
val allEndpoints = EndpointsView.getAllEndpoints(starterDetails)
228+
val mainContentRaw = MainView.getProperMainContent(starterDetails)
229+
// Remove package declaration from mainContent since we already have it in the template
230+
val mainContent = mainContentRaw.linesIterator
231+
.dropWhile(line => line.trim.startsWith("package"))
232+
.mkString(System.lineSeparator())
233+
234+
val allImports = toSortedList(
235+
helloServerEndpoint.imports ++ metricsEndpoint.imports ++ docEndpoints.imports
236+
++ jsonEndpoint.imports ++ library.imports ++ allEndpoints.imports
237+
)
218238

219239
val content = txt
220-
.scalaCliBuild(
240+
.scalaCliSingleFile(
221241
starterDetails.projectName,
222-
starterDetails.groupId,
242+
groupId,
223243
starterDetails.scalaVersion.value,
224-
dependencies
244+
dependencies,
245+
allImports,
246+
helloServerEndpoint.body,
247+
jsonEndpoint.body,
248+
library.body,
249+
apiEndpoints.body,
250+
docEndpoints.body,
251+
metricsEndpoint.body,
252+
allEndpoints.body,
253+
mainContent,
254+
starterDetails.scalaVersion
225255
)
226256
.toString()
227-
GeneratedFile("project.scala", content)
228-
}
229257

230-
private lazy val readme: GeneratedFile =
231-
GeneratedFile(CommonObjectTemplate.readMePath, CommonObjectTemplate.templateResource("README_scala-cli.md"))
258+
GeneratedFile(s"${starterDetails.projectName}.scala", content)
259+
}
232260

233-
private lazy val gitignore: GeneratedFile = GeneratedFile(".gitignore", txt.gitignore(List(".bsp/", ".scala-build/")).toString())
261+
private def toSortedList(set: Set[Import]): List[Import] = set.toList.sortBy(_.fullName)
234262

235-
end ScalaCliProjectTemplate
263+
end ScalaCliSingleFileTemplate
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
@(
2+
name: String,
3+
groupId: String,
4+
scalaVersion: String,
5+
dependencies: String,
6+
additionalImports: List[com.softwaremill.adopttapir.template.scala.Import],
7+
helloEndpointServer: String,
8+
jsonEndpoint: String,
9+
library: String,
10+
apiEndpoints: String,
11+
docEndpoints: String,
12+
metricsEndpoint: String,
13+
allEndpoints: String,
14+
mainContent: String,
15+
scalaVersionEnum: com.softwaremill.adopttapir.starter.ScalaVersion,
16+
leftBracket: Char = '{',
17+
rightBracket: Char = '}'
18+
)
19+
//> using scala @scalaVersion
20+
@dependencies
21+
22+
/*
23+
* Single-file Scala CLI project
24+
* Run: scala-cli run @{name}.scala
25+
* Compile: scala-cli compile @{name}.scala
26+
* Format: scala-cli fmt @{name}.scala
27+
*
28+
* More info: https://tapir.softwaremill.com | https://scala-cli.virtuslab.org
29+
*/
30+
31+
package @groupId
32+
33+
import sttp.tapir.@if(scalaVersionEnum == com.softwaremill.adopttapir.starter.ScalaVersion.Scala2){_}else{*}
34+
@for(additionalImport <- additionalImports) {
35+
@{additionalImport.asScalaImport(scalaVersionEnum)}}
36+
37+
object Endpoints@if(scalaVersionEnum == com.softwaremill.adopttapir.starter.ScalaVersion.Scala2){ @leftBracket}else{:}
38+
case class User(name: String) extends AnyVal
39+
val helloEndpoint: PublicEndpoint[User, Unit, String, Any] = endpoint.get
40+
.in("hello")
41+
.in(query[User]("name"))
42+
.out(stringBody)
43+
@helloEndpointServer
44+
@if(jsonEndpoint.nonEmpty){
45+
@jsonEndpoint}
46+
@apiEndpoints
47+
@if(docEndpoints.nonEmpty){
48+
@docEndpoints}
49+
@if(metricsEndpoint.nonEmpty){
50+
@metricsEndpoint}
51+
@allEndpoints
52+
@if(scalaVersionEnum == com.softwaremill.adopttapir.starter.ScalaVersion.Scala2){@rightBracket}
53+
@if(library.nonEmpty){
54+
@library
55+
}
56+
@mainContent
57+

backend/src/test/scala/com/softwaremill/adopttapir/starter/api/StarterApiTest.scala

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -73,44 +73,36 @@ class StarterApiTest extends BaseTest with TestDependencies {
7373
response.code.code shouldBe 200
7474
checkStreamZipContent(response.body) { unpackedDir =>
7575
unpackedDir.listRecursively.toList.filter(_.isRegularFile).map(_.path.getFileName.toString) should contain theSameElementsAs List(
76-
"project.scala",
77-
".scalafmt.conf",
78-
"EndpointsSpec.scala",
79-
"Endpoints.scala",
80-
"Main.scala",
81-
"README.md",
82-
".gitignore",
83-
"logback.xml"
76+
s"${req.projectName}.scala"
8477
)
8578
}
8679
}
8780

88-
for req <- Seq(validSbtRequest, validScalaCliRequest) do {
89-
it should s"have relative paths associated with groupId in request for .scala files for ${req.builder} builder" in {
90-
// given
91-
val groupIdRelativePath = s"${req.groupId.replace('.', '/')}"
92-
93-
// when
94-
val response: Response[fs2.Stream[IO, Byte]] = requests.requestZip(req)
95-
96-
// then
97-
response.code.code shouldBe 200
98-
checkStreamZipContent(response.body) { unpackedDir =>
99-
val paths = unpackedDir.listRecursively.toList
100-
.collect {
101-
case f: File
102-
if f.path.toString.endsWith("README.md") ||
103-
f.path.toString.endsWith(".scala") && !f.path.endsWith("project.scala") && f.isRegularFile =>
104-
unpackedDir.relativize(f)
105-
}
106-
val root = req.projectName
107-
paths.map(_.toString) should contain theSameElementsAs List(
108-
s"$root/README.md",
109-
s"$root/$mainPath/$groupIdRelativePath/Main.scala",
110-
s"$root/src/main/scala/$groupIdRelativePath/Endpoints.scala",
111-
s"$root/src/test/scala/$groupIdRelativePath/EndpointsSpec.scala"
112-
)
113-
}
81+
it should "have relative paths associated with groupId in request for .scala files for Sbt builder" in {
82+
// given
83+
val req = validSbtRequest
84+
val groupIdRelativePath = s"${req.groupId.replace('.', '/')}"
85+
86+
// when
87+
val response: Response[fs2.Stream[IO, Byte]] = requests.requestZip(req)
88+
89+
// then
90+
response.code.code shouldBe 200
91+
checkStreamZipContent(response.body) { unpackedDir =>
92+
val paths = unpackedDir.listRecursively.toList
93+
.collect {
94+
case f: File
95+
if f.path.toString.endsWith("README.md") ||
96+
f.path.toString.endsWith(".scala") && !f.path.endsWith("project.scala") && f.isRegularFile =>
97+
unpackedDir.relativize(f)
98+
}
99+
val root = req.projectName
100+
paths.map(_.toString) should contain theSameElementsAs List(
101+
s"$root/README.md",
102+
s"$root/$mainPath/$groupIdRelativePath/Main.scala",
103+
s"$root/src/main/scala/$groupIdRelativePath/Endpoints.scala",
104+
s"$root/src/test/scala/$groupIdRelativePath/EndpointsSpec.scala"
105+
)
114106
}
115107
}
116108

backend/src/test/scala/com/softwaremill/adopttapir/test/ServiceFactory.scala

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,10 @@ abstract class GeneratedService:
6868
override def toString: String = s"[$timestamp]"
6969

7070
class ServiceFactory:
71-
import ServiceTimeouts.waitForScalaCliCompileAndUnitTest
72-
7371
def create(builder: Builder, tempDir: better.files.File): IO[GeneratedService] =
7472
builder match {
7573
case Builder.Sbt => IO.blocking(SbtService(tempDir))
76-
case Builder.ScalaCli => IO.blocking(ScalaCliService(tempDir)).timeoutAndForget(waitForScalaCliCompileAndUnitTest)
74+
case Builder.ScalaCli => IO.blocking(ScalaCliService(tempDir))
7775
}
7876

7977
private case class SbtService(tempDir: better.files.File) extends GeneratedService:
@@ -95,14 +93,17 @@ class ServiceFactory:
9593
override protected val portPattern = new Regex("^(?:Go to |Server started at )http://localhost:(\\d+).*")
9694

9795
override protected lazy val process: SubProcess =
98-
// one cannot chain multiple targets to scala-cli hence 'test' target (that implicitly calls compile) is called in
99-
// blocking manner and once it returns with success (0 exit code) the configuration is actually started
100-
val compileAndTest = os.proc("scala-cli", "--power", "test", ".").call(cwd = os.Path(tempDir.toJava), mergeErrIntoOut = true)
96+
// For single-file projects, we just compile and run (no tests)
97+
// Get all .scala files in the directory (os.proc doesn't expand globs)
98+
val scalaFiles = os.list(os.Path(tempDir.toJava)).filter(_.ext == "scala").map(_.last).toSeq
99+
assert(scalaFiles.nonEmpty, s"No .scala files found in ${tempDir}")
100+
101+
val compile = os.proc("scala-cli", "compile" +: scalaFiles).call(cwd = os.Path(tempDir.toJava), mergeErrIntoOut = true)
101102
assert(
102-
compileAndTest.exitCode == 0,
103-
s"Compilation and unit tests exited with [${compileAndTest.exitCode}] and output:${System
104-
.lineSeparator()}${compileAndTest.out.lines().mkString(System.lineSeparator())}"
103+
compile.exitCode == 0,
104+
s"Compilation exited with [${compile.exitCode}] and output:${System
105+
.lineSeparator()}${compile.out.lines().mkString(System.lineSeparator())}"
105106
)
106107

107-
os.proc("scala-cli", "--power", "run", ".")
108+
os.proc("scala-cli", "run" +: scalaFiles)
108109
.spawn(cwd = os.Path(tempDir.toJava), env = Map("HTTP_PORT" -> "0"), mergeErrIntoOut = true)

ui/src/components/FileTreeView/FileTreeView.component.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ export const FileTreeView = ({ tree, setOpenedFile }: Props) => {
5454
};
5555

5656
const findAndSetMainNodeId = (tree: Tree): void => {
57+
// First, check if this is a single-file project (has a .scala file at root level)
58+
const rootScalaFiles = tree.filter(file => typeof file.content === 'string' && file.name.endsWith('.scala'));
59+
60+
if (rootScalaFiles.length === 1) {
61+
// Single-file project: open the single .scala file
62+
if (rootScalaFiles[0].id !== undefined) {
63+
setMainNodeId(rootScalaFiles[0].id);
64+
}
65+
return;
66+
}
67+
68+
// Multi-file project: look for Main.scala
5769
tree.forEach(file => {
5870
if (file.id !== undefined && file.name === 'Main.scala') {
5971
setMainNodeId(file.id);

0 commit comments

Comments
 (0)