Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.specificlanguages.mops.protocol.ProtocolJson
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.parallel.ResourceLock
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertTrue

Expand All @@ -29,6 +30,21 @@ class MakeIntegrationTest {
)
}

@Test
fun `make module leaves out a solution created with the not-generated preset`() {
val project = daemons.project("mps-json", copy = "not-generated")
val created = project.run("create", "solution", "example.analyzed")
assertEquals(0, created.exitCode, created.output)
val model = project.run("create", "model", "example.analyzed.main", "--module", "example.analyzed")
assertEquals(0, model.exitCode, model.output)

val result = project.run("make", "module", "example.analyzed")

assertEquals(0, result.exitCode, result.output)
assertEquals("nothing to generate", result.stdout.trim(), result.output)
assertContains(result.stderr, "warning\tnot generating the models of example.analyzed")
}

companion object {
@JvmField
@RegisterExtension
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ internal const val MAKE_OUTPUT_HELP =
"a one-line action report on stdout: `made <N> module(s): success`, `made <N> module(s): FAILED (<N> " +
"error(s))`, or `nothing to generate`. The command exits non-zero when the make reports errors."

/** What a make deletes, for the help footers of both make commands. */
/** What a make generates and deletes, for the help footers of both make commands. */
internal const val MAKE_CLEAN_HELP =
"The make generates every model of the made modules and deletes files in their output folders that it no " +
"longer generates."
"longer generates. It leaves out the models of modules without generation-target facets, such as " +
"solutions created with the `not-generated` usage preset, and names each such module in a warning."

/** How the generation options behave, for the help footers of both make commands. */
internal const val MAKE_GENERATION_HELP =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import com.specificlanguages.mops.daemon.core.MpsRequestException
import com.specificlanguages.mops.protocol.MakeGenerationOptions
import com.specificlanguages.mops.protocol.MakeMessageJson
import com.specificlanguages.mops.protocol.MakeMessageKind
import com.specificlanguages.mops.protocol.MakeMessageLevel
import com.specificlanguages.mops.protocol.MakeOutcome
import com.specificlanguages.mops.protocol.MakeResponse
import jetbrains.mps.generator.GenerationFacade
import jetbrains.mps.generator.GenerationSettingsProvider
import jetbrains.mps.generator.IModifiableGenerationSettings
import jetbrains.mps.make.MakeSession
import jetbrains.mps.make.dependencies.MakeSequence
import jetbrains.mps.make.resources.IResource
import jetbrains.mps.make.script.IResult
import jetbrains.mps.make.script.IScriptController
import jetbrains.mps.make.service.CoreMakeTask
import jetbrains.mps.messages.IMessage
Expand All @@ -21,6 +24,7 @@ import jetbrains.mps.progress.EmptyProgressMonitor
import jetbrains.mps.project.AbstractModule
import jetbrains.mps.project.Project
import jetbrains.mps.project.dependency.GlobalModuleDependenciesManager
import jetbrains.mps.project.facets.GenerationTargetFacet
import jetbrains.mps.smodel.Language
import jetbrains.mps.smodel.adapter.structure.MetaAdapterFactory
import jetbrains.mps.smodel.resources.ModelsToResources
Expand All @@ -36,8 +40,9 @@ import org.jetbrains.mps.openapi.util.ProgressMonitor
* The make framework builds exactly the modules whose models it is handed — it never pulls in a dependency that is not
* in the set, it only orders what it is given. So to make un-made dependencies too, [makeModules] first expands the
* requested modules to their transitive compile-dependency closure ([GlobalModuleDependenciesManager]); already-built
* library dependencies drop out because [ModelsToResources] keeps only generatable models. Every model handed to the
* make is generated; nothing skips unmodified models.
* library dependencies drop out because [ModelsToResources] keeps only generatable models. Models of modules without
* generation-target facets are left out too ([makeSet]). Every model handed to the make is generated; nothing skips
* unmodified models.
*
* Model collection runs inside short read actions; the make call runs outside any model action, on the calling thread,
* because the make framework acquires its own model locks.
Expand Down Expand Up @@ -95,43 +100,58 @@ class ProjectMake(private val project: Project) {
monitor: ProgressMonitor,
): MakeResponse {
val options = makeGenerationOptions(generationSettings, generation)
// resources() reads the model graph and keeps only generatable models, grouped one MResource per module.
// But "generatable" is not the same as "writable": MPS ships its library languages and runtime solutions as
// *source* models inside `-src.jar` files, and those models report themselves generatable, so they survive
// ModelsToResources — yet writing their generation output into the jar fails ("Write for jar files is not
// supported"). We only ever intend to make project sources, so drop models of read-only (packaged) modules
// first. See https://github.com/specificlanguages/mps-api-research/blob/main/make-generation.md.
val resources: List<IResource> = project.modelAccess.computeReadAction<List<IResource>> {
ModelsToResources(models.filter { it.isInWritableModule() }, /* cleanMake = */ true).resources().toList()
}

if (resources.isEmpty()) {
return MakeResponse(MakeOutcome.NOTHING_TO_GENERATE, moduleCount = 0, messages = emptyList())
val makeSet = project.modelAccess.computeReadAction<MakeSet> { makeSet(models) }
val handler = CollectingMessageHandler()
makeSet.leftOutModules.mapTo(handler.messages) {
MakeMessageJson(MakeMessageKind.WARNING, it.notGeneratedMessage())
}

val handler = CollectingMessageHandler()
// MakeSession's own clean flag only names the IDE's make script; the resources above carry the clean make.
val session = MakeSession(project, handler, /* cleanMake = */ false)
// The make sequence leaves out a module whose models use no language and import no language or devkit, so it
// can have no step even when there are resources.
val sequence = MakeSequence(makeSet.resources, null, session)
if (sequence.steps() == 0) {
return MakeResponse(MakeOutcome.NOTHING_TO_GENERATE, 0, handler.messages.reportedAt(generation.messages))
}

// Reimplements BuildMakeService.doMake using classes from mps-core: BuildMakeService itself ships only in
// lib/mpsant/mps-tool.jar, which is not on the daemon classpath. A null script lets MPS build the correct
// per-cluster script; a null-controller default is IScriptController.Stub2. See the mps-api-research notes.
val controller: IScriptController = makeScriptController(session, options)
val task = CoreMakeTask(MakeSequence(resources, null, session), controller, handler)
val task = CoreMakeTask(sequence, controller, handler)
val result = withMakeGenerationSettings(generationSettings, generation) {
task.run(monitor)
task.getResult()
}

// MPS's make result is not reliable on its own — it can report success while errors were reported — so a run
// counts as successful only when the result is successful AND no error message was seen.
val succeeded = result != null && result.isSucessful && !handler.errorOccurred
return MakeResponse(
outcome = if (succeeded) MakeOutcome.SUCCESS else MakeOutcome.FAILED,
moduleCount = resources.size,
messages = handler.messages.filter { generation.messages.reports(it.kind) },
)
return makeResponse(result, handler.messages, makeSet.resources.size, generation.messages)
}

/**
* The make resources for [models], grouped one per module, and the modules whose generatable models are left out
* because they have nowhere to write generated files. Must run inside a read action.
*
* Models of read-only modules are left out: MPS ships its library languages and runtime solutions as *source*
* models inside `-src.jar` files, and those models report themselves generatable, yet writing their generation
* output into the jar fails ("Write for jar files is not supported"). Models whose module has no generation-target
* facets are left out too: text generation has no output location for them, which fails the make on MPS before
* 2025.3. See https://github.com/specificlanguages/mps-api-research/blob/main/make-generation.md.
*/
private fun makeSet(models: List<SModel>): MakeSet {
val writable = models.filter { it.isInWritableModule() }
val (withTarget, withoutTarget) = writable.partition { GenerationTargetFacet.find(it) != null }
val leftOutModules = withoutTarget.filter(GenerationFacade::canGenerate).mapNotNull { it.module }.distinct()
// resources() keeps only generatable models.
val resources = ModelsToResources(withTarget, /* cleanMake = */ true).resources().toList()
return MakeSet(resources, leftOutModules)
}

private class MakeSet(val resources: List<IResource>, val leftOutModules: List<SModule>)

private fun SModule.notGeneratedMessage(): String =
"not generating the models of $moduleName: the module has no generation-target facets"

/**
* The modules to make for a request over [requested]: the requested modules, their module-level compile-dependency
* closure, and — because MPS treats a used language as an already-built dependency and never adds its source module
Expand Down Expand Up @@ -168,8 +188,7 @@ class ProjectMake(private val project: Project) {
}

// A model belongs to a writable module when its owning module is not read-only. Read-only modules are the deployed,
// jar-packaged libraries MPS ships pre-built; they must never be regenerated. Models with no resolvable module are
// kept — they are not the packaged-library case this guards against.
// jar-packaged libraries MPS ships pre-built; they must never be regenerated.
private fun SModel.isInWritableModule(): Boolean =
(module as? AbstractModule)?.isReadOnly != true

Expand All @@ -188,23 +207,55 @@ class ProjectMake(private val project: Project) {
}

/**
* Collects the messages MPS reports during the make and records whether any error was seen. A message with no kind
* is treated as an error, matching how MPS's own headless tooling handles it.
* Collects the messages MPS reports during the make. A message with no kind is treated as an error, matching how
* MPS's own headless tooling handles it.
*/
private class CollectingMessageHandler : IMessageHandler {
val messages = mutableListOf<MakeMessageJson>()
var errorOccurred = false
private set

override fun handle(msg: IMessage) {
val kind = when (msg.kind) {
MessageKind.INFORMATION -> MakeMessageKind.INFO
MessageKind.WARNING -> MakeMessageKind.WARNING
else -> MakeMessageKind.ERROR
}
if (kind == MakeMessageKind.ERROR) errorOccurred = true
messages.add(MakeMessageJson(kind, msg.text.orEmpty()))
}
}
}

/**
* The response for a make that ran with [result] and reported [messages], keeping the messages that [level] reports.
*
* MPS's make result is not reliable on its own — it can report success while errors were reported — so a make
* succeeds only when [result] is successful and no error message was seen. A failed make always carries an error: when
* MPS reported none, the response says so with the reason from [result], if any.
*/
internal fun makeResponse(
result: IResult?,
messages: List<MakeMessageJson>,
moduleCount: Int,
level: MakeMessageLevel?,
): MakeResponse {
val hasError = messages.any { it.kind == MakeMessageKind.ERROR }
val succeeded = result != null && result.isSucessful && !hasError
val reported = messages.reportedAt(level)
return MakeResponse(
outcome = if (succeeded) MakeOutcome.SUCCESS else MakeOutcome.FAILED,
moduleCount = moduleCount,
messages = if (succeeded || hasError) reported else reported + unexplainedFailure(result),
)
}

private fun List<MakeMessageJson>.reportedAt(level: MakeMessageLevel?): List<MakeMessageJson> =
filter { level.reports(it.kind) }

private fun unexplainedFailure(result: IResult?): MakeMessageJson {
val reason = result?.message().orEmpty().ifBlank { null }
val text = if (reason == null) {
"make failed without reporting an error"
} else {
"make failed without reporting an error: $reason"
}
return MakeMessageJson(MakeMessageKind.ERROR, text)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.specificlanguages.mops.daemon

import com.specificlanguages.mops.protocol.MakeMessageJson
import com.specificlanguages.mops.protocol.MakeMessageKind
import com.specificlanguages.mops.protocol.MakeMessageLevel
import com.specificlanguages.mops.protocol.MakeOutcome
import com.specificlanguages.mops.protocol.MakeResponse
import jetbrains.mps.make.script.IResult
import kotlin.test.Test
import kotlin.test.assertEquals

/** The outcome and messages of a make, from the MPS make result and the messages the make reported. */
class MakeResponseTest {
private val warning = MakeMessageJson(MakeMessageKind.WARNING, "a warning")
private val error = MakeMessageJson(MakeMessageKind.ERROR, "an error")
private val info = MakeMessageJson(MakeMessageKind.INFO, "an info")

@Test
fun `a successful result without errors succeeds with the reported messages`() {
val response = makeResponse(IResult.SUCCESS(null), listOf(info, warning), moduleCount = 2, level = null)

assertEquals(MakeResponse(MakeOutcome.SUCCESS, 2, listOf(warning)), response)
}

@Test
fun `a successful result with an error fails with that error`() {
val response = makeResponse(IResult.SUCCESS(null), listOf(warning, error), moduleCount = 1, level = null)

assertEquals(MakeResponse(MakeOutcome.FAILED, 1, listOf(warning, error)), response)
}

@Test
fun `a failed result without an error message fails with the reason from the result`() {
val response = makeResponse(IResult.FAILURE("not started", null), listOf(warning), moduleCount = 1, level = null)

val reason = MakeMessageJson(MakeMessageKind.ERROR, "make failed without reporting an error: not started")
assertEquals(MakeResponse(MakeOutcome.FAILED, 1, listOf(warning, reason)), response)
}

@Test
fun `a missing result fails with an error even at the error message level`() {
val response = makeResponse(null, listOf(warning), moduleCount = 1, level = MakeMessageLevel.ERROR)

val reason = MakeMessageJson(MakeMessageKind.ERROR, "make failed without reporting an error")
assertEquals(MakeResponse(MakeOutcome.FAILED, 1, listOf(reason)), response)
}
}
Loading
Loading