Allow referring to remote project dependencies on the CLI with @-notation (#1434)

This commit is contained in:
Jen Basch
2026-02-23 08:52:56 -08:00
committed by GitHub
parent 77395a86f4
commit 2ec0baad53
16 changed files with 212 additions and 33 deletions

View File

@@ -52,8 +52,6 @@ constructor(
private val errStream: OutputStream = System.err, private val errStream: OutputStream = System.err,
) : CliCommand(options) { ) : CliCommand(options) {
private val normalizedSourceModule = options.normalizedSourceModules.first()
override fun doRun() { override fun doRun() {
val builder = evaluatorBuilder() val builder = evaluatorBuilder()
try { try {
@@ -68,7 +66,7 @@ constructor(
val evaluator = builder.build() val evaluator = builder.build()
evaluator.use { evaluator.use {
evaluator.evaluateCommand( evaluator.evaluateCommand(
uri(normalizedSourceModule), uri(resolvedSourceModules.first()),
reservedFlagNames, reservedFlagNames,
reservedFlagShortNames, reservedFlagShortNames,
) { spec -> ) { spec ->

View File

@@ -111,12 +111,11 @@ constructor(
} }
private fun resolveOutputPaths(pathStr: String): Map<URI, Path> { private fun resolveOutputPaths(pathStr: String): Map<URI, Path> {
val moduleUris = options.base.normalizedSourceModules
val workingDir = options.base.normalizedWorkingDir val workingDir = options.base.normalizedWorkingDir
// used just to resolve the `%{moduleName}` placeholder // used just to resolve the `%{moduleName}` placeholder
val moduleResolver = ModuleResolver(moduleKeyFactories(ModulePathResolver.empty())) val moduleResolver = ModuleResolver(moduleKeyFactories(ModulePathResolver.empty()))
return moduleUris.associateWith { uri -> return resolvedSourceModules.associateWith { uri ->
val moduleDir: String? = val moduleDir: String? =
IoUtils.toPath(uri)?.let { IoUtils.toPath(uri)?.let {
IoUtils.relativize(it.parent, workingDir).toString().ifEmpty { "." } IoUtils.relativize(it.parent, workingDir).toString().ifEmpty { "." }
@@ -192,7 +191,7 @@ constructor(
} }
} else { } else {
var outputWritten = false var outputWritten = false
for (moduleUri in options.base.normalizedSourceModules) { for (moduleUri in resolvedSourceModules) {
val moduleSource = toModuleSource(moduleUri, inputStream) val moduleSource = toModuleSource(moduleUri, inputStream)
if (options.expression != null) { if (options.expression != null) {
val output = evaluator.evaluateExpressionString(moduleSource, options.expression) val output = evaluator.evaluateExpressionString(moduleSource, options.expression)

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -66,7 +66,7 @@ constructor(
try { try {
return builder return builder
.apply { .apply {
for ((idx, sourceModule) in options.base.normalizedSourceModules.withIndex()) { for ((idx, sourceModule) in resolvedSourceModules.withIndex()) {
addExternalProperty("pkl.analyzeImports.$idx", sourceModule.toString()) addExternalProperty("pkl.analyzeImports.$idx", sourceModule.toString())
} }
} }

View File

@@ -47,7 +47,7 @@ constructor(
private fun evalTest(builder: EvaluatorBuilder) { private fun evalTest(builder: EvaluatorBuilder) {
val sources = val sources =
options.normalizedSourceModules.ifEmpty { project?.tests?.map { it.toUri() } } resolvedSourceModules.ifEmpty { project?.tests?.map { it.toUri() } }
?: ?:
// keep in sync with error message thrown by clikt // keep in sync with error message thrown by clikt
throw CliException( throw CliException(

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -16,7 +16,6 @@
package org.pkl.cli.commands package org.pkl.cli.commands
import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.path import com.github.ajalt.clikt.parameters.types.path
@@ -24,9 +23,10 @@ import java.nio.file.Path
import org.pkl.cli.CliImportAnalyzer import org.pkl.cli.CliImportAnalyzer
import org.pkl.cli.CliImportAnalyzerOptions import org.pkl.cli.CliImportAnalyzerOptions
import org.pkl.commons.cli.commands.ModulesCommand import org.pkl.commons.cli.commands.ModulesCommand
import org.pkl.commons.cli.commands.NoOpCommand
import org.pkl.commons.cli.commands.single import org.pkl.commons.cli.commands.single
class AnalyzeCommand : NoOpCliktCommand(name = "analyze") { class AnalyzeCommand : NoOpCommand(name = "analyze") {
override fun help(context: Context) = "Commands related to static analysis" override fun help(context: Context) = "Commands related to static analysis"
override fun helpEpilog(context: Context) = "For more information, visit $helpLink" override fun helpEpilog(context: Context) = "For more information, visit $helpLink"

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -17,7 +17,6 @@ package org.pkl.cli.commands
import com.github.ajalt.clikt.completion.CompletionCandidates import com.github.ajalt.clikt.completion.CompletionCandidates
import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.arguments.multiple
@@ -30,10 +29,11 @@ import java.nio.file.Path
import org.pkl.cli.CliProjectPackager import org.pkl.cli.CliProjectPackager
import org.pkl.cli.CliProjectResolver import org.pkl.cli.CliProjectResolver
import org.pkl.commons.cli.commands.BaseCommand import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.commons.cli.commands.NoOpCommand
import org.pkl.commons.cli.commands.TestOptions import org.pkl.commons.cli.commands.TestOptions
import org.pkl.commons.cli.commands.single import org.pkl.commons.cli.commands.single
class ProjectCommand : NoOpCliktCommand(name = "project") { class ProjectCommand : NoOpCommand(name = "project") {
override fun help(context: Context) = "Run commands related to projects" override fun help(context: Context) = "Run commands related to projects"
override fun helpEpilog(context: Context) = "For more information, visit $helpLink" override fun helpEpilog(context: Context) = "For more information, visit $helpLink"

View File

@@ -17,21 +17,22 @@ package org.pkl.cli.commands
import com.github.ajalt.clikt.completion.CompletionCommand import com.github.ajalt.clikt.completion.CompletionCommand
import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.context import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
import org.pkl.commons.cli.commands.NoOpCommand
import org.pkl.commons.cli.commands.installCommonOptions import org.pkl.commons.cli.commands.installCommonOptions
import org.pkl.core.Release import org.pkl.core.Release
internal val helpLink = "${Release.current().documentation.homepage}pkl-cli/index.html#usage" internal val helpLink = "${Release.current().documentation.homepage}pkl-cli/index.html#usage"
class RootCommand : NoOpCliktCommand(name = "pkl") { class RootCommand : NoOpCommand(name = "pkl") {
override val printHelpOnEmptyArgs = true override val printHelpOnEmptyArgs = true
override fun helpEpilog(context: Context) = "For more information, visit $helpLink" override fun helpEpilog(context: Context) = "For more information, visit $helpLink"
init { init {
context { context {
readArgumentFile = null
suggestTypoCorrection = { given, possible -> suggestTypoCorrection = { given, possible ->
if (!given.startsWith("-")) { if (!given.startsWith("-")) {
registeredSubcommands().map { it.commandName } registeredSubcommands().map { it.commandName }

View File

@@ -49,7 +49,11 @@ class RunCommand : BaseCommand(name = "run", helpLink = helpLink) {
private val showHelp by option("-h", "--help", help = "Show this message and exit").flag() private val showHelp by option("-h", "--help", help = "Show this message and exit").flag()
val module: URI? by val module: URI? by
argument(name = "module", completionCandidates = CompletionCandidates.Path) argument(
name = "module",
help = "Root pkl:Command module to invoke.",
completionCandidates = CompletionCandidates.Path,
)
.convert { BaseOptions.parseModuleName(it) } .convert { BaseOptions.parseModuleName(it) }
.optional() .optional()

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -31,7 +31,7 @@ class CliJavaCodeGenerator(private val options: CliJavaCodeGeneratorOptions) :
val builder = evaluatorBuilder() val builder = evaluatorBuilder()
try { try {
builder.build().use { evaluator -> builder.build().use { evaluator ->
for (moduleUri in options.base.normalizedSourceModules) { for (moduleUri in resolvedSourceModules) {
val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri)) val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri))
val codeGenerator = JavaCodeGenerator(schema, options.toJavaCodeGeneratorOptions()) val codeGenerator = JavaCodeGenerator(schema, options.toJavaCodeGeneratorOptions())
try { try {

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -32,7 +32,7 @@ class CliKotlinCodeGenerator(private val options: CliKotlinCodeGeneratorOptions)
try { try {
builder.build().use { evaluator -> builder.build().use { evaluator ->
for (moduleUri in options.base.normalizedSourceModules) { for (moduleUri in resolvedSourceModules) {
val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri)) val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri))
val codeGenerator = KotlinCodeGenerator(schema, options.toKotlinCodeGeneratorOptions()) val codeGenerator = KotlinCodeGenerator(schema, options.toKotlinCodeGeneratorOptions())
try { try {

View File

@@ -175,11 +175,19 @@ data class CliBaseOptions(
/** [rootDir] after normalization. */ /** [rootDir] after normalization. */
val normalizedRootDir: Path? = rootDir?.let(normalizedWorkingDir::resolve) val normalizedRootDir: Path? = rootDir?.let(normalizedWorkingDir::resolve)
/** The effective project directory, if exists. */
val normalizedProjectFile: Path? by lazy {
projectDir?.resolve(ProjectDependenciesManager.PKL_PROJECT_FILENAME)
?: normalizedWorkingDir.getProjectFile(rootDir)
}
/** [sourceModules] after normalization. */ /** [sourceModules] after normalization. */
val normalizedSourceModules: List<URI> = val normalizedSourceModules: List<URI> =
sourceModules sourceModules
.map { uri -> .map { uri ->
if (uri.isAbsolute) uri else IoUtils.resolve(normalizedWorkingDir.toUri(), uri) if (uri.isAbsolute) uri
else if (uri.path.startsWith("@") && !noProject && normalizedProjectFile != null) uri
else IoUtils.resolve(normalizedWorkingDir.toUri(), uri)
} }
// sort modules to make cli output independent of source module order // sort modules to make cli output independent of source module order
.sorted() .sorted()
@@ -195,12 +203,6 @@ data class CliBaseOptions(
/** [moduleCacheDir] after normalization. */ /** [moduleCacheDir] after normalization. */
val normalizedModuleCacheDir: Path? = moduleCacheDir?.let(normalizedWorkingDir::resolve) val normalizedModuleCacheDir: Path? = moduleCacheDir?.let(normalizedWorkingDir::resolve)
/** The effective project directory, if exists. */
val normalizedProjectFile: Path? by lazy {
projectDir?.resolve(ProjectDependenciesManager.PKL_PROJECT_FILENAME)
?: normalizedWorkingDir.getProjectFile(rootDir)
}
/** [caCertificates] after normalization. */ /** [caCertificates] after normalization. */
val normalizedCaCertificates: List<Path> = caCertificates.map(normalizedWorkingDir::resolve) val normalizedCaCertificates: List<Path> = caCertificates.map(normalizedWorkingDir::resolve)
} }

View File

@@ -86,6 +86,35 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
} }
} }
protected fun resolveModuleUri(uri: URI): URI =
if (uri.isAbsolute) uri
else { // must be @dep/mod.pkl notation!!
if (!uri.path.startsWith('@'))
throw CliBugException(
RuntimeException("tried to resolve project URI `$uri` with no @ prefix")
)
if (project == null)
throw CliBugException(
RuntimeException("tried to resolve project URI `$uri` with no project present")
)
val dep = uri.path.substringBefore('/').drop(1)
val path = uri.path.dropWhile { it != '/' }
if (path.isEmpty()) throw CliException("Invalid project dependency URI `$uri`.")
val remoteDep =
project!!.dependencies.remoteDependencies()[dep]
?: if (project!!.dependencies.localDependencies().containsKey(dep))
throw CliException(
"Only remote project dependencies may be referenced using @-notation. Dependency `@$dep` is a local dependency."
)
else throw CliException("Project does not contain dependency `@$dep`.")
remoteDep.packageUri.toPackageAssetUri(path).uri
}
protected val resolvedSourceModules: List<URI> =
if (project == null) cliOptions.normalizedSourceModules
else cliOptions.normalizedSourceModules.map(::resolveModuleUri)
protected fun loadProject(projectFile: Path): Project { protected fun loadProject(projectFile: Path): Project {
val securityManager = val securityManager =
SecurityManagers.standard( SecurityManagers.standard(

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -17,9 +17,14 @@ package org.pkl.commons.cli.commands
import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.parameters.groups.provideDelegate import com.github.ajalt.clikt.parameters.groups.provideDelegate
abstract class BaseCommand(name: String, private val helpLink: String) : CliktCommand(name = name) { abstract class BaseCommand(name: String, private val helpLink: String) : CliktCommand(name = name) {
init {
context { readArgumentFile = null }
}
abstract val helpString: String abstract val helpString: String
override fun help(context: Context) = helpString override fun help(context: Context) = helpString

View File

@@ -0,0 +1,25 @@
/*
* Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.commons.cli.commands
import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.context
open class NoOpCommand(name: String? = null) : NoOpCliktCommand(name) {
init {
context { readArgumentFile = null }
}
}

View File

@@ -16,11 +16,18 @@
package org.pkl.commons.cli package org.pkl.commons.cli
import com.github.ajalt.clikt.core.parse import com.github.ajalt.clikt.core.parse
import java.net.URI
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.writeText
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import org.pkl.commons.cli.commands.BaseCommand import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.core.SecurityManagers import org.pkl.core.SecurityManagers
@OptIn(ExperimentalPathApi::class)
class CliCommandTest { class CliCommandTest {
class CliTest(options: CliBaseOptions) : CliCommand(options) { class CliTest(options: CliBaseOptions) : CliCommand(options) {
@@ -28,6 +35,7 @@ class CliCommandTest {
val myAllowedResources = allowedResources val myAllowedResources = allowedResources
val myAllowedModules = allowedModules val myAllowedModules = allowedModules
val myResolvedSourceModules = resolvedSourceModules
} }
private val cmd = private val cmd =
@@ -55,7 +63,7 @@ class CliCommandTest {
"scheme+ext=reader5 with args", "scheme+ext=reader5 with args",
) )
) )
val opts = cmd.baseOptions.baseOptions(emptyList(), null, true) val opts = cmd.baseOptions.baseOptions(emptyList(), testMode = true)
val cliTest = CliTest(opts) val cliTest = CliTest(opts)
assertThat(cliTest.myAllowedModules.map { it.pattern() }) assertThat(cliTest.myAllowedModules.map { it.pattern() })
.isEqualTo( .isEqualTo(
@@ -68,4 +76,112 @@ class CliCommandTest {
listOf("\\Qscheme1:\\E", "\\Qscheme2:\\E", "\\Qscheme+ext:\\E") listOf("\\Qscheme1:\\E", "\\Qscheme2:\\E", "\\Qscheme+ext:\\E")
) )
} }
@Test
fun `@-notation package URIs - treated as relative paths when no project present`(
@TempDir tempDir: Path
) {
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val cliTest = CliTest(opts)
assertThat(cliTest.myResolvedSourceModules)
.isEqualTo(listOf(tempDir.toUri().resolve("@foo/bar.pkl")))
}
@Test
fun `@-notation package URIs - empty paths are rejected`(@TempDir tempDir: Path) {
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@no.slash")), testMode = true)
val exc = assertThrows<CliException> { CliTest(opts) }
assertThat(exc.message).isEqualTo("Invalid project dependency URI `@no.slash`.")
}
@Test
fun `@-notation package URIs - missing dependencies are rejected`(@TempDir tempDir: Path) {
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val exc = assertThrows<CliException> { CliTest(opts) }
assertThat(exc.message).isEqualTo("Project does not contain dependency `@foo`.")
}
@Test
fun `@-notation package URIs - local dependencies are rejected`(
@TempDir tempDir: Path,
@TempDir depDir: Path,
) {
depDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
package {
name = "foo"
baseUri = "package://example.com/foo"
version = "0.0.1"
packageZipUrl = "https://example.com/foo@\(version).zip"
}
"""
.trimIndent()
)
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
dependencies {
["foo"] = import("$depDir/PklProject")
}
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val exc = assertThrows<CliException> { CliTest(opts) }
assertThat(exc.message)
.isEqualTo(
"Only remote project dependencies may be referenced using @-notation. Dependency `@foo` is a local dependency."
)
}
@Test
fun `@-notation package URIs - remote dependencies are resolved`(@TempDir tempDir: Path) {
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
dependencies {
["foo"] {
uri = "package://example.com/foo@1.2.3"
}
}
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val cliTest = CliTest(opts)
assertThat(cliTest.myResolvedSourceModules)
.isEqualTo(listOf(tempDir.toUri().resolve("package://example.com/foo@1.2.3#/bar.pkl")))
}
} }

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -159,7 +159,7 @@ class CliDocGenerator(
val regularModuleUris = mutableListOf<URI>() val regularModuleUris = mutableListOf<URI>()
val pklProjectPaths = mutableSetOf<Path>() val pklProjectPaths = mutableSetOf<Path>()
val packageUris = mutableListOf<PackageUri>() val packageUris = mutableListOf<PackageUri>()
for (moduleUri in options.base.normalizedSourceModules) { for (moduleUri in resolvedSourceModules) {
if (moduleUri.scheme == "file") { if (moduleUri.scheme == "file") {
val dir = moduleUri.toPath().parent val dir = moduleUri.toPath().parent
val projectFile = dir.getProjectFile(options.base.normalizedRootDir) val projectFile = dir.getProjectFile(options.base.normalizedRootDir)