diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54afc0c..43c6068 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,14 +22,36 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + # 17 builds and runs Maven (the last version is the default); the + # build provider is also tested on 8, the oldest plugin.json accepts. - uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: | + 8 + 17 - name: Check script process deadlines and cleanup run: node scripts/test-process.mjs + # Until 1lck/Lithe-IDEA tags sdk-java-v, sdk.lock.json pins no + # SHA-256 and package.mjs needs LITHE_SDK_JAR: build it from source.ref. + # Remove this step once the lock pins the released JAR. + - name: Build the unreleased plugin SDK + run: | + if [ "$(node -p 'require("./sdk.lock.json").sha256')" != "null" ]; then exit 0; fi + repository=$(node -p 'require("./sdk.lock.json").source.repository') + ref=$(node -p 'require("./sdk.lock.json").source.ref') + git clone --depth 1 --filter=blob:none --sparse --branch "$ref" "$repository" "$RUNNER_TEMP/lithe" + git -C "$RUNNER_TEMP/lithe" sparse-checkout set sdk/java + mvn -B -ntp -q -f "$RUNNER_TEMP/lithe/$(node -p 'require("./sdk.lock.json").source.pom')" -DskipTests package + echo "LITHE_SDK_JAR=$RUNNER_TEMP/lithe/$(node -p 'require("./sdk.lock.json").source.jar')" >> "$GITHUB_ENV" - name: Check manifest and build the archive run: node scripts/package.mjs + - name: Test the build provider's snapshot with a fake Maven (Java 17) + run: node scripts/test-backend.mjs + - name: Test the build provider's snapshot with a fake Maven (Java 8) + env: + LITHE_TEST_JAVA: ${{ env.JAVA_HOME_8_X64 }}/bin/java + run: node scripts/test-backend.mjs - name: Download Maven ${{ matrix.maven }} if: matrix.maven != 'runner' env: @@ -40,7 +62,10 @@ jobs: echo "$(curl -fsSL "$base.sha512" | cut -d' ' -f1) $RUNNER_TEMP/maven.tar.gz" | sha512sum -c - tar -xzf "$RUNNER_TEMP/maven.tar.gz" -C "$RUNNER_TEMP" echo "MVN=$RUNNER_TEMP/apache-maven-$VERSION/bin/mvn" >> "$GITHUB_ENV" - - name: Run the declared commands with Maven + - name: Run the provider and the declared commands with Maven + env: + # The oldest Maven also gets the oldest Java for the provider. + LITHE_TEST_JAVA: ${{ matrix.maven == 'runner' && 'java' || format('{0}/bin/java', env.JAVA_HOME_8_X64) }} run: node scripts/test.mjs - name: Validate against Lithe Store rules if: matrix.maven == 'runner' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49d7a39..ef73e8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,10 +23,19 @@ jobs: - uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: | + 8 + 17 + # --expect-tag refuses LITHE_SDK_JAR and a lock without a released SDK. - name: Build the archive - run: node scripts/package.mjs --expect-tag "$GITHUB_REF_NAME" | tee "$RUNNER_TEMP/checksums.txt" - - name: Run the declared commands with Maven + run: | + set -o pipefail + node scripts/package.mjs --expect-tag "$GITHUB_REF_NAME" | tee "$RUNNER_TEMP/checksums.txt" + - name: Test the build provider's snapshot on Java 8 + env: + LITHE_TEST_JAVA: ${{ env.JAVA_HOME_8_X64 }}/bin/java + run: node scripts/test-backend.mjs + - name: Run the provider and the declared commands with Maven run: node scripts/test.mjs - name: Publish release env: diff --git a/.gitignore b/.gitignore index 4524f83..162897b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ dist/ .artifacts/ extension/target/ .DS_Store +backend/target/ +.cache/ diff --git a/README.md b/README.md index 8867675..0e22a9e 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ 为 [Lithe](https://github.com/1lck/Lithe-IDEA) 提供 Maven 项目支持:识别 `pom.xml`,在 Maven 工具窗口里列出 模块、生命周期和插件目标,运行任务和测试,查看依赖树。 这是一个**社区插件**,用 Lithe 的声明式构建系统接口(插件 API 3 的 `buildSystems`)实现, -除了下面说的一个 Maven 扩展 jar,没有任何宿主代码。 +除了下面说的 Maven 扩展 jar 和读取项目用的 Java 后端,没有任何宿主代码。 > English: Maven support for Lithe through the declarative `buildSystems` plugin API — project tree, lifecycle and > plugin goals, profiles, test runs with JUnit reports, compiler diagnostics and the dependency tree. It runs your own -> Maven with a small bundled core extension that only reads the reactor. +> Maven with a small bundled core extension that only reads the reactor, from a Java build provider that keeps the +> result in a snapshot, so reopening a project does not run Maven again until a POM or setting changes. > **试用版**:0.1.0 作为预发布版本提供,需要基于 Lithe `preview`、包含 `buildSystems` 插件接口及原生构建窗口支持的开发版。宿主支持 PR 合并前,请使用包含该改动的开发构建。 @@ -22,7 +23,8 @@ | 选项 | Profiles(从反应堆、父 POM 和 settings.xml 读取,可手动补充)、Settings 文件、本地仓库;工具栏开关 Offline(`-o`)和 Skip Tests(`-DskipTests`) | | 测试 | 运行模块、测试类或单个方法,读取 Surefire/Failsafe 的 JUnit XML 报告 | | 诊断 | 把编译输出中的 `[ERROR]`/`[WARNING] 文件:[行,列]` 显示为问题 | -| 依赖 | 按模块显示依赖树(含作用域和传递依赖),反应堆内未安装的兄弟模块也能解析 | +| 依赖 | 按模块显示依赖树(含作用域和传递依赖),反应堆内未安装的兄弟模块也能解析;一次 Maven 运行读出全部模块,展开模块不再各跑一次 | +| 快照 | 项目和依赖保存在工作区状态目录,重新打开项目时直接显示;POM、`.mvn/` 配置、settings.xml、选项或 Maven/Java 变化后才重新运行 Maven,Reload 总是重新运行 | | 同步 | `pom.xml`、`.mvn/maven.config`、`.mvn/extensions.xml` 变化后重新读取项目 | ## 依赖的程序 @@ -33,7 +35,7 @@ 2. 环境变量 `MAVEN_HOME` 下的 `bin/mvn`; 3. `PATH` 里的 `mvn`。 -要求 Maven 3.6.3 或更新版本、Java 8 或更新版本。没有 Maven 的话可以先安装: +要求 Maven 3.6.3 或更新版本、Java 8 或更新版本(读取项目的后端也用 Lithe 找到的这个 Java 运行)。没有 Maven 的话可以先安装: ```bash brew install maven @@ -41,13 +43,21 @@ brew install maven ## 工作方式 -读取项目和依赖时,插件用 `-Dmaven.ext.class.path` 给你的 Maven 加载随包提供的 -`lib/lithe-maven-extension.jar`(源码在 `extension/`),运行一个没有默认绑定的阶段 `pre-clean`: - -- 扩展在 Maven 读完反应堆后,把模块、内容根、插件目标和 Profiles 写成 Lithe 的 `litheBuildModel` 文档; - 依赖则写成 `litheDependencies` 文档。文件位置由 Lithe 指定(`-Dlithe.modelOutput` / `-Dlithe.dependenciesOutput`)。 +读取项目和依赖由插件的构建后端 `lib/lithe-maven-backend.jar`(源码在 `backend/`)负责。Lithe 在第一次需要 +项目模型时用 Java 启动它,工作区关闭时结束它: + +- 需要重新读取时,后端用 `-Dmaven.ext.class.path` 给你的 Maven 加载随包提供的 + `lib/lithe-maven-extension.jar`(源码在 `extension/`),运行一个没有默认绑定的阶段 `pre-clean`。 + 扩展在 Maven 读完反应堆后,把模块、内容根、插件目标和 Profiles 写成 Lithe 的 `litheBuildModel` 文档, + 同时把**每个模块**的依赖树写成 `litheDependencies` 文档(`-Dlithe.modelOutput` / `-Dlithe.dependenciesOutput`)。 +- 结果连同输入指纹保存在 Lithe 给这个工作区的状态目录里的 `maven-snapshot.json`。指纹包含根目录下所有 + `pom.xml`(跳过 `target/`、`.git/`、`node_modules/`)、`.mvn/maven.config`、`extensions.xml`、`jvm.config`、 + Wrapper 配置、用户和全局 `settings.xml`、`-s` 指定的文件、选项、Maven 可执行文件及其修改时间、Java 版本和扩展版本。 + 指纹不变时,重新打开项目或展开模块直接用快照,不启动 Maven。 +- 同时到达的请求共用一次 Maven 运行;全部请求都取消后才结束 Maven。运行期间 POM 被修改时,本次结果照常显示但不保存。 +- 指纹看不到的变化(例如根目录外的父 POM、远程仓库里更新的 SNAPSHOT)请点 Reload,它总是重新运行 Maven。 - 不会编译或打包,也不会生成 `target/`;解析依赖时只读 POM,不下载依赖 jar。 -- 没有这些属性时扩展什么都不做;插件目录在运行时保持只读。 +- 没有 Lithe 的属性时扩展什么都不做;插件目录在运行时保持只读。 读取项目会执行 POM 中声明的 Maven 扩展和插件,首次读取还可能联网下载插件描述,所以 Lithe 会先确认你信任这个工作区。 @@ -65,14 +75,35 @@ Lithe 已预置 Lithe Store 社区插件源;如果你之前移除了它,先 io.github.lithe-idea.maven-build/plugin.json # 插件描述,目录名必须等于插件 id io.github.lithe-idea.maven-build/icons/ # 工具窗口和工具栏图标(来源见 NOTICE) extension/ # Maven 核心扩展,导出模型和依赖树 +backend/ # 构建后端(Java 8,Lithe 插件 SDK),快照和 Maven 运行 +sdk.lock.json # 打进后端的 Lithe Java 插件 SDK:版本、下载地址、SHA-256 test/fixtures/reactor/ # 三个模块的示例反应堆,供集成测试使用 scripts/package.mjs # 构建扩展并打包成 dist/maven-build-<版本>-darwin.zip -scripts/test.mjs # 用真实 Maven 运行清单里的命令并检查输出 +scripts/test.mjs # 用真实 Maven 经后端读取项目、运行清单里的命令并检查输出 +scripts/test-backend.mjs # 用假 Maven 计数,检查快照命中、失效、刷新、合并运行和取消 .github/workflows/ci.yml # PR 时打包、用 Maven 3.6.3 和最新版测试,并用 Lithe Store 的规则检查 .github/workflows/release.yml # 推送 v<版本> 标签时发布 GitHub Release ``` -本地需要 Node.js 22+、JDK 8+ 和 Maven(还需要 `zip`): +本地需要 Node.js 22+、JDK(CI 用 17;扩展和后端都按 Java 8 编译,后端运行只需 Java 8)和 Maven(还需要 `zip`)。 + +后端打进 Lithe 的 Java 插件 SDK。打包脚本按 `sdk.lock.json` 下载 SDK JAR 到 `.cache/sdk/`,SHA-256 不符就拒绝。 +SDK 的第一个版本(`sdk-java-v0.1.0`)发布前,锁文件还没有 SHA-256,需要从 +[1lck/Lithe-IDEA#1379](https://github.com/1lck/Lithe-IDEA/pull/1379) 的分支自行构建,再用 `LITHE_SDK_JAR` 指定: + +```bash +mvn -B -f sdk/java/pom.xml verify +``` + +(在 Lithe-IDEA 仓库里运行,产出 `sdk/java/plugin-sdk/target/lithe-plugin-sdk-java-0.1.0.jar`。) +锁文件有 SHA-256 后,`LITHE_SDK_JAR` 指向的 JAR 也必须与之相同;发布(`--expect-tag`)不接受 `LITHE_SDK_JAR`。 + +```bash +LITHE_SDK_JAR=/path/to/lithe-plugin-sdk-java-0.1.0.jar node scripts/package.mjs +``` + +SDK 发布后直接运行: + ```bash node scripts/package.mjs @@ -82,6 +113,13 @@ node scripts/package.mjs node scripts/test.mjs ``` +后端的快照行为不需要 Maven,用假 Maven 计数验证;`LITHE_TEST_JAVA=/path/to/java` 可指定运行后端的 Java +(CI 用 Java 8 和 17 各跑一次,`test.mjs` 同样读取这个变量): + +```bash +node scripts/test-backend.mjs +``` + `MVN=/path/to/mvn` 可为打包和测试指定 Maven 版本。脚本在 macOS/Linux 上运行;每次 Maven 调用默认限时 120 秒, 可用 `LITHE_MAVEN_TIMEOUT_MS` 调整(1–600000 毫秒);`zip` 限时 30 秒。到期或收到 SIGINT/SIGTERM 时, 脚本会终止本次启动的进程组,500 毫秒后升级为 SIGKILL,再最多等待 2 秒并报告清理失败。 @@ -113,7 +151,8 @@ node scripts/verify-scripts.mjs --host-bundle /Applications/Lithe.app ## 发布新版本 -1. 同时修改 `plugin.json` 和 `extension/pom.xml` 的版本(例如 `0.2.0`;打包脚本会检查两者一致),合入 `main`。 +1. 同时修改 `plugin.json`、`extension/pom.xml` 和 `backend/pom.xml` 的版本(例如 `0.2.0`;打包脚本会检查三者一致), + 确认 `sdk.lock.json` 已填入正式发布的 SDK 地址和 SHA-256,合入 `main`。 2. 推送同名标签: ```bash diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..021e0c1 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,136 @@ + + + + 4.0.0 + + io.github.lithe-idea + lithe-maven-backend + + 0.1.0 + jar + Lithe Maven build provider + + + UTF-8 + + 8 + + 2026-10-20T00:00:00Z + + ${project.basedir}/missing-lithe-sdk.jar + + + + + io.github.lithe-idea + lithe-plugin-sdk-java + 0.1.0 + system + ${lithe.sdk.jar} + + + + + lithe-maven-backend + + + org.apache.maven.plugins + maven-jar-plugin + + + + ${project.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + false + + + ${lithe.sdk.jar} + + + + *:* + + META-INF/MANIFEST.MF + + + + + + io.github.litheidea.maven.backend.MavenBackend + + ${project.version} + + + + + + + + + + + + org.apache.maven.plugins + maven-clean-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + org.apache.maven.plugins + maven-install-plugin + 3.1.3 + + + + + diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/Fingerprint.java b/backend/src/main/java/io/github/litheidea/maven/backend/Fingerprint.java new file mode 100644 index 0000000..9ece30a --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/Fingerprint.java @@ -0,0 +1,230 @@ +package io.github.litheidea.maven.backend; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * A digest of everything that can change what Maven reports for a root. A + * snapshot is only reused when its fingerprint equals the current one. + * + * + * + *

Inputs outside these, such as a parent POM outside the root or a changed + * SNAPSHOT in a remote repository, need an explicit refresh. + */ +final class Fingerprint { + /** Bumped when the inputs below change meaning. */ + private static final int VERSION = 1; + private static final int MAX_DEPTH = 24; + private static final int MAX_ENTRIES = 50_000; + private static final Set SKIPPED = new HashSet<>(Arrays.asList("target", ".git", "node_modules")); + private static final List ENVIRONMENT = Arrays.asList( + "JAVA_HOME", "MAVEN_HOME", "M2_HOME", "MAVEN_OPTS", "MAVEN_ARGS", "MAVEN_CONFIG"); + private static final List SETTINGS_FLAGS = Arrays.asList( + "-s", "--settings", "-gs", "--global-settings", "-t", "--toolchains", "-gt", "--global-toolchains"); + + private final MessageDigest digest; + + private Fingerprint() { + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + static String of(Inputs inputs) throws IOException { + Fingerprint fingerprint = new Fingerprint(); + fingerprint.add("fingerprint", Integer.toString(VERSION)); + fingerprint.add("backend", MavenBackend.VERSION); + fingerprint.add("root", inputs.root.toString()); + fingerprint.addPoms(inputs.root); + for (String name : Arrays.asList("maven.config", "extensions.xml", "jvm.config", "wrapper/maven-wrapper.properties")) { + fingerprint.addFile(".mvn/" + name, inputs.root.resolve(".mvn").resolve(name)); + } + + Path userHome = Paths.get(System.getProperty("user.home")); + fingerprint.addFile("userSettings", userHome.resolve(".m2/settings.xml")); + fingerprint.addFile("userToolchains", userHome.resolve(".m2/toolchains.xml")); + Path mavenHome = mavenHome(inputs); + if (mavenHome != null) { + fingerprint.add("mavenHome", mavenHome.toString()); + fingerprint.addFile("globalSettings", mavenHome.resolve("conf/settings.xml")); + } + List configArguments = mavenConfig(inputs.root); + for (List arguments : Arrays.asList(inputs.optionArguments, configArguments)) { + for (String file : settingsFiles(arguments)) { + fingerprint.addFile("settingsFile " + file, inputs.root.resolve(file)); + } + } + + fingerprint.add("optionArguments", String.join("\u0000", inputs.optionArguments)); + for (Map.Entry entry : new TreeMap<>(inputs.hostEnvironment).entrySet()) { + fingerprint.add("hostEnvironment " + entry.getKey(), entry.getValue()); + } + for (String name : ENVIRONMENT) { + fingerprint.add("environment " + name, inputs.environment.get(name)); + } + fingerprint.add("maven", inputs.maven.toString()); + fingerprint.addStamp("mavenStamp", inputs.maven); + fingerprint.add("java.version", System.getProperty("java.version")); + fingerprint.addFile("extension", inputs.extensionJar); + return fingerprint.hex(); + } + + /** {@code -s file}, {@code --settings file}, {@code --settings=file} and {@code -sfile} forms. */ + static List settingsFiles(List arguments) { + List files = new java.util.ArrayList<>(); + for (int index = 0; index < arguments.size(); index++) { + String argument = arguments.get(index); + for (String flag : SETTINGS_FLAGS) { + if (argument.equals(flag) && index + 1 < arguments.size()) { + files.add(arguments.get(++index)); + break; + } + if (flag.startsWith("--") && argument.startsWith(flag + "=")) { + files.add(argument.substring(flag.length() + 1)); + break; + } + } + } + return files; + } + + /** The Maven installation an {@code mvn} script belongs to; none for a wrapper. */ + private static Path mavenHome(Inputs inputs) { + if (inputs.maven.getFileName().toString().startsWith("mvnw")) { + return null; + } + try { + Path real = inputs.maven.toRealPath(); + Path bin = real.getParent(); + return bin == null || bin.getParent() == null ? null : bin.getParent(); + } catch (IOException error) { + return null; + } + } + + private static List mavenConfig(Path root) { + Path config = root.resolve(".mvn/maven.config"); + if (!Files.isRegularFile(config)) { + return Collections.emptyList(); + } + try { + String text = new String(Files.readAllBytes(config), StandardCharsets.UTF_8).trim(); + return text.isEmpty() ? Collections.emptyList() : Arrays.asList(text.split("\\s+")); + } catch (IOException error) { + return Collections.emptyList(); + } + } + + private void addPoms(Path root) throws IOException { + TreeMap poms = new TreeMap<>(); + int[] entries = {0}; + boolean[] truncated = {false}; + Files.walkFileTree(root, Collections.emptySet(), MAX_DEPTH, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { + if (!directory.equals(root) && SKIPPED.contains(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return count(); + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (attributes.isRegularFile() && file.getFileName().toString().equals("pom.xml")) { + poms.put(root.relativize(file).toString(), sha256(Files.readAllBytes(file))); + } + return count(); + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException error) { + return count(); + } + + private FileVisitResult count() { + if (++entries[0] > MAX_ENTRIES) { + truncated[0] = true; + return FileVisitResult.TERMINATE; + } + return FileVisitResult.CONTINUE; + } + }); + for (Map.Entry pom : poms.entrySet()) { + add("pom " + pom.getKey(), pom.getValue()); + } + add("pomWalk", truncated[0] ? "truncated" : "complete"); + } + + private void addFile(String label, Path path) throws IOException { + if (Files.isRegularFile(path)) { + add(label, sha256(Files.readAllBytes(path))); + } else { + add(label, null); + } + } + + private void addStamp(String label, Path path) { + try { + Path real = path.toRealPath(); + add(label, real + " " + Files.getLastModifiedTime(real).toMillis() + " " + Files.size(real)); + } catch (IOException error) { + add(label, null); + } + } + + private void add(String label, String value) { + digest.update(label.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + // Absent and empty differ. + digest.update(value == null ? new byte[] {1} : ("=" + value).getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + } + + private String hex() { + return hex(digest.digest()); + } + + private static String sha256(byte[] bytes) { + try { + return hex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + private static String hex(byte[] bytes) { + StringBuilder text = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + text.append(Character.forDigit((value >> 4) & 0xf, 16)).append(Character.forDigit(value & 0xf, 16)); + } + return text.toString(); + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/Inputs.java b/backend/src/main/java/io/github/litheidea/maven/backend/Inputs.java new file mode 100644 index 0000000..05a1eb6 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/Inputs.java @@ -0,0 +1,28 @@ +package io.github.litheidea.maven.backend; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** What one Maven run reads, as resolved for one request. */ +final class Inputs { + final Path root; + final Path maven; + final List optionArguments; + /** The environment Maven runs with. */ + final Map environment; + /** The part of it the host's options contributed. */ + final Map hostEnvironment; + final Path extensionJar; + + Inputs(Path root, Path maven, List optionArguments, Map environment, + Map hostEnvironment, Path extensionJar) { + this.root = root; + this.maven = maven; + this.optionArguments = Collections.unmodifiableList(optionArguments); + this.environment = Collections.unmodifiableMap(environment); + this.hostEnvironment = Collections.unmodifiableMap(hostEnvironment); + this.extensionJar = extensionJar; + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java b/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java new file mode 100644 index 0000000..353a817 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/MavenBackend.java @@ -0,0 +1,190 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.Backend; +import io.github.litheidea.plugin.sdk.CallContext; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import io.github.litheidea.plugin.sdk.StateStore; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Lithe's build provider for Maven. It answers {@code lithe/build/model} and + * {@code lithe/build/dependencies} for every Maven root of one workspace. + * + *

One Maven run with the bundled extension writes both the model and the + * dependency tree of every module. The result is kept in + * {@code maven-snapshot.json} in the workspace state directory, so a request + * whose inputs (see {@link Fingerprint}) did not change, also after Lithe + * restarted, is answered without starting Maven. A request with + * {@code refresh: true} always runs Maven. + */ +public final class MavenBackend { + static final String VERSION = version(); + + private final Backend backend = Backend.create("lithe-maven-backend", VERSION); + private final String stateDirectory; + private volatile Path workspaceRoot; + private volatile Snapshots snapshots; + private volatile MavenRuns runs; + + private MavenBackend(String stateDirectory) { + this.stateDirectory = stateDirectory; + } + + public static void main(String[] arguments) { + new MavenBackend(argument(arguments, "--state")).serve(); + } + + private void serve() { + backend.onInitialize((params, negotiated) -> { + workspaceRoot = workspaceRoot(Json.string(params, "rootUri")); + Path state = null; + if (stateDirectory != null && !stateDirectory.isEmpty()) { + // `--state ${workspaceStateDir}`: writable and kept across restarts. + StateStore store = StateStore.open(Paths.get(stateDirectory)); + snapshots = new Snapshots(store, backend); + state = store.directory(); + } else { + backend.log("No --state directory: Maven results are not kept across restarts."); + } + runs = new MavenRuns(snapshots, backend, state); + }); + backend.onModel((params, context) -> copy(load(params, context).model())); + backend.onDependencies(this::dependencies); + backend.listen(); + } + + private Object dependencies(Map params, CallContext context) throws Exception { + Path root = root(params); + MavenRuns.Loaded loaded = load(params, context); + Object selected = params.get("project"); + if (!(selected instanceof Map)) { + return copy(loaded.dependencies()); + } + String path = Json.string(Json.object(selected), "path"); + if (path == null) { + throw new PluginError("maven.invalidProject", "The selected project has no path."); + } + Map result = new LinkedHashMap<>(); + result.put("dependencies", Projects.dependenciesOf(loaded, root, path)); + return result; + } + + private MavenRuns.Loaded load(Map params, CallContext context) throws Exception { + if (runs == null) { + throw new PluginError("maven.notInitialized", "The Maven backend was not initialized."); + } + Path root = root(params); + if (!Files.isDirectory(root)) { + throw new PluginError("maven.missingRoot", root + " is not a directory."); + } + Map environment = environment(params.get("environment")); + Object tool = params.get("tool"); + String executable = tool instanceof Map ? Json.string(Json.object(tool), "executable") : null; + Path maven = MavenExecutable.resolve(executable, root, environment); + Inputs inputs = new Inputs(root, maven, strings(params.get("optionArguments")), + environment, hostEnvironment(params.get("environment")), extensionJar()); + boolean refresh = Boolean.TRUE.equals(params.get("refresh")); + return runs.load(inputs, refresh, context.cancellation()); + } + + private Path root(Map params) { + String root = Json.string(params, "root"); + Path base = workspaceRoot == null ? Paths.get("").toAbsolutePath() : workspaceRoot; + return base.resolve(root == null || root.isEmpty() ? "." : root).toAbsolutePath().normalize(); + } + + /** + * The backend's own environment with what the host's options contribute + * on top. JAVA_HOME defaults to the runtime Lithe started this backend + * with, which is the JDK it found for Maven too. + */ + private static Map environment(Object fromHost) { + Map environment = new LinkedHashMap<>(System.getenv()); + environment.putAll(hostEnvironment(fromHost)); + if (!environment.containsKey("JAVA_HOME")) { + environment.put("JAVA_HOME", System.getProperty("java.home")); + } + return environment; + } + + private static Map hostEnvironment(Object fromHost) { + Map result = new LinkedHashMap<>(); + if (fromHost instanceof Map) { + for (Map.Entry entry : ((Map) fromHost).entrySet()) { + if (entry.getKey() instanceof String && entry.getValue() instanceof String) { + result.put((String) entry.getKey(), (String) entry.getValue()); + } + } + } + return result; + } + + private static List strings(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (Object item : (List) value) { + if (item instanceof String) { + result.add((String) item); + } + } + return result; + } + + /** The extension ships next to this JAR: {@code lib/lithe-maven-extension.jar}. */ + private static Path extensionJar() { + try { + Path self = Paths.get(MavenBackend.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Path extension = self.resolveSibling("lithe-maven-extension.jar"); + if (Files.isRegularFile(extension)) { + return extension; + } + throw new PluginError("maven.missingExtension", + "The plugin package is missing lib/lithe-maven-extension.jar. Reinstall the Maven plugin."); + } catch (URISyntaxException | RuntimeException error) { + if (error instanceof PluginError) { + throw (PluginError) error; + } + throw new PluginError("maven.missingExtension", "The Maven plugin cannot locate its extension.", error); + } + } + + private static Path workspaceRoot(String rootUri) throws IOException { + if (rootUri == null) { + return Paths.get("").toAbsolutePath(); + } + // `Paths.get(URI)` accepts the trailing slash a directory URI may carry. + return Paths.get(URI.create(rootUri)).toAbsolutePath().normalize(); + } + + /** A shallow copy: the SDK stamps {@code format} on the returned document. */ + private static Map copy(Map document) { + return new LinkedHashMap<>(document); + } + + private static String version() { + String version = MavenBackend.class.getPackage().getImplementationVersion(); + return version == null ? "development" : version; + } + + private static String argument(String[] arguments, String name) { + for (int index = 0; index + 1 < arguments.length; index++) { + if (arguments[index].equals(name)) { + return arguments[index + 1]; + } + } + return null; + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/MavenExecutable.java b/backend/src/main/java/io/github/litheidea/maven/backend/MavenExecutable.java new file mode 100644 index 0000000..de46eb4 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/MavenExecutable.java @@ -0,0 +1,54 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.PluginError; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; + +/** + * The Maven a run uses. Lithe resolves the executable from plugin.json's + * {@code invocation} (wrapper, MAVEN_HOME, PATH, the user's override) and + * passes it as {@code tool.executable}. A host that predates that parameter + * passes nothing; the same order is then followed here. + */ +final class MavenExecutable { + private MavenExecutable() {} + + static Path resolve(String fromHost, Path root, Map environment) { + if (fromHost != null && !fromHost.isEmpty()) { + Path path = root.resolve(fromHost).toAbsolutePath().normalize(); + if (!Files.isRegularFile(path) || !Files.isExecutable(path)) { + throw new PluginError("maven.notFound", "The Maven executable " + path + " is missing or not executable."); + } + return path; + } + Path wrapper = root.resolve("mvnw"); + if (Files.isRegularFile(root.resolve(".mvn/wrapper/maven-wrapper.properties")) && Files.isExecutable(wrapper)) { + return wrapper; + } + String home = environment.get("MAVEN_HOME"); + if (home != null && !home.isEmpty()) { + Path mvn = Paths.get(home, "bin", "mvn"); + if (Files.isRegularFile(mvn) && Files.isExecutable(mvn)) { + return mvn.toAbsolutePath().normalize(); + } + } + String path = environment.get("PATH"); + if (path != null) { + for (String directory : path.split(File.pathSeparator)) { + if (directory.isEmpty()) { + continue; + } + Path mvn = Paths.get(directory, "mvn"); + if (Files.isRegularFile(mvn) && Files.isExecutable(mvn)) { + return mvn.toAbsolutePath().normalize(); + } + } + } + throw new PluginError("maven.notFound", + "Lithe found no Maven: no Maven Wrapper in the project, no MAVEN_HOME and no mvn on PATH. " + + "Install Maven 3.6.3 or newer, for example with `brew install maven`."); + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/MavenRuns.java b/backend/src/main/java/io/github/litheidea/maven/backend/MavenRuns.java new file mode 100644 index 0000000..7dc04b6 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/MavenRuns.java @@ -0,0 +1,264 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.Backend; +import io.github.litheidea.plugin.sdk.Cancellation; +import io.github.litheidea.plugin.sdk.CancelledException; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import io.github.litheidea.plugin.sdk.Processes; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import java.util.stream.Stream; + +/** + * Answers a request from the snapshot, or from one Maven run shared by every + * request that needs it. + * + *

A run belongs to no single request: it goes on while any request still + * waits for it and is stopped once all of them were cancelled. Its result is + * saved only when the inputs did not change while Maven ran, so a POM edited + * mid-run never hides behind a snapshot of the old one. + */ +final class MavenRuns { + /** The model and the dependency tree of every module of one root. */ + static final class Loaded { + private final Map model; + private final Map dependencies; + + Loaded(Map model, Map dependencies) { + this.model = model; + this.dependencies = dependencies; + } + + Map model() { + return model; + } + + Map dependencies() { + return dependencies; + } + } + + /** One Maven run and the requests waiting for it. */ + private static final class Flight { + final String key; + final CompletableFuture result = new CompletableFuture<>(); + final Cancellation cancellation = new Cancellation(); + int waiters; + + Flight(String key) { + this.key = key; + } + } + + private static final int ERROR_LINES = 40; + + private final Snapshots snapshots; + private final Backend backend; + private final Path workDirectory; + private final Map flights = new HashMap<>(); + private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "maven-run"); + thread.setDaemon(true); + return thread; + }); + + /** + * @param workDirectory where a run's output files go; the system's + * temporary directory when null + */ + MavenRuns(Snapshots snapshots, Backend backend, Path workDirectory) { + this.snapshots = snapshots; + this.backend = backend; + this.workDirectory = workDirectory; + } + + Loaded load(Inputs inputs, boolean refresh, Cancellation request) throws Exception { + String fingerprint = Fingerprint.of(inputs); + if (!refresh && snapshots != null) { + Loaded stored = snapshots.read(inputs.root, fingerprint); + if (stored != null) { + return stored; + } + } + request.throwIfCancelled(); + Flight flight; + synchronized (flights) { + String key = inputs.root + "\u0000" + fingerprint; + flight = flights.get(key); + if (flight == null) { + Flight started = new Flight(key); + flights.put(key, started); + executor.execute(() -> fly(started, inputs, fingerprint)); + flight = started; + } + flight.waiters++; + } + Flight joined = flight; + CompletableFuture mine = new CompletableFuture<>(); + joined.result.whenComplete((value, error) -> { + if (error != null) { + mine.completeExceptionally(error); + } else { + mine.complete(value); + } + }); + Cancellation.Registration registration = request.onCancel(() -> { + leave(joined); + mine.completeExceptionally(new CancelledException("The request was cancelled.")); + }); + try { + return mine.get(); + } catch (ExecutionException error) { + Throwable cause = error.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw error; + } finally { + registration.remove(); + } + } + + /** The last waiter leaving stops the run. */ + private void leave(Flight flight) { + synchronized (flights) { + if (--flight.waiters > 0 || flight.result.isDone()) { + return; + } + flights.remove(flight.key, flight); + } + flight.cancellation.cancel("Every request waiting for Maven was cancelled."); + } + + private void fly(Flight flight, Inputs inputs, String before) { + try { + Loaded loaded = run(inputs, flight.cancellation); + if (snapshots != null) { + if (before.equals(Fingerprint.of(inputs))) { + snapshots.write(inputs.root, before, loaded); + } else { + backend.log("The Maven project changed while it was read; the result is not saved."); + } + } + flight.result.complete(loaded); + } catch (Throwable error) { + flight.result.completeExceptionally(error); + } finally { + synchronized (flights) { + flights.remove(flight.key, flight); + } + } + } + + private Loaded run(Inputs inputs, Cancellation cancellation) throws IOException { + Path work = workDirectory == null + ? Files.createTempDirectory("lithe-maven-") + : Files.createTempDirectory(workDirectory, "run-"); + try { + Path model = work.resolve("model.json"); + Path dependencies = work.resolve("dependencies.json"); + List command = new ArrayList<>(); + command.add(inputs.maven.toString()); + command.add("-B"); + command.add("-ntp"); + command.addAll(inputs.optionArguments); + command.add("-Dmaven.ext.class.path=" + inputs.extensionJar); + command.add("-Dlithe.modelOutput=" + model); + // No lithe.dependenciesProject: every module's tree, in the same run. + command.add("-Dlithe.dependenciesOutput=" + dependencies); + command.add("pre-clean"); + Tail tail = new Tail(ERROR_LINES); + Processes.Result result = Processes.run(command, new Processes.Options() + .directory(inputs.root) + .environment(inputs.environment) + .cancellation(cancellation) + .maxOutputBytes(64 * 1024) + .onStdout(tail) + .onStderr(tail)); + if (result.exitCode() != 0) { + String output = tail.errors(); + backend.log("Maven exited with status " + result.exitCode() + ":\n" + output); + throw new PluginError("maven.loadFailed", "Maven could not read the project (exit status " + + result.exitCode() + ")." + (output.isEmpty() ? "" : "\n" + output)); + } + return new Loaded(document(model, "model"), document(dependencies, "dependency tree")); + } finally { + delete(work); + } + } + + private static Map document(Path file, String what) { + try { + Object value = Json.parse(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); + if (value instanceof Map) { + return Json.object(value); + } + } catch (IOException | RuntimeException error) { + throw new PluginError("maven.invalidOutput", "Maven finished without writing the " + what + ".", error); + } + throw new PluginError("maven.invalidOutput", "Maven wrote an unreadable " + what + "."); + } + + private static void delete(Path directory) { + try (Stream paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete()); + } catch (IOException ignored) { + // A leftover file in the state directory is harmless. + } + } + + /** The last lines of Maven's output, for the error a failed run reports. */ + private static final class Tail implements Consumer { + private final int limit; + private final Deque lines = new ArrayDeque<>(); + private final StringBuilder partial = new StringBuilder(); + + Tail(int limit) { + this.limit = limit; + } + + @Override + public synchronized void accept(String text) { + partial.append(text); + int end; + while ((end = partial.indexOf("\n")) >= 0) { + lines.addLast(partial.substring(0, end)); + partial.delete(0, end + 1); + if (lines.size() > limit) { + lines.removeFirst(); + } + } + } + + /** {@code [ERROR]} lines when there are any, otherwise the whole tail. */ + synchronized String errors() { + List all = new ArrayList<>(lines); + if (partial.length() > 0) { + all.add(partial.toString()); + } + List errors = new ArrayList<>(); + for (String line : all) { + if (line.startsWith("[ERROR]")) { + errors.add(line); + } + } + String text = String.join("\n", errors.isEmpty() ? all : errors).trim(); + return text.length() > 4000 ? text.substring(text.length() - 4000) : text; + } + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java b/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java new file mode 100644 index 0000000..37464f3 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/Projects.java @@ -0,0 +1,69 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Picks one project's subtree out of the workspace dependency document. + * + *

The extension lists every reactor project as a node with its + * coordinates but without its path; the model has both. A selected path is + * matched to a model project, and that project's coordinates to its node. + */ +final class Projects { + private Projects() {} + + static List dependenciesOf(MavenRuns.Loaded loaded, Path root, String path) { + Path wanted = root.resolve(path).toAbsolutePath().normalize(); + List projects = list(loaded.model().get("projects")); + List nodes = list(loaded.dependencies().get("dependencies")); + for (int index = 0; index < projects.size(); index++) { + Map project = Json.object(projects.get(index)); + String projectPath = Json.string(project, "path"); + if (projectPath == null || !samePath(wanted, root.resolve(projectPath))) { + continue; + } + Map properties = Json.object(project.get("properties")); + for (Object candidate : nodes) { + Map node = Json.object(candidate); + if (Objects.equals(node.get("group"), properties.get("groupId")) + && Objects.equals(node.get("name"), properties.get("artifactId")) + && Objects.equals(node.get("version"), properties.get("version"))) { + return new ArrayList<>(list(node.get("children"))); + } + } + // Same order as the reactor, when coordinates are missing. + if (nodes.size() == projects.size() + && Objects.equals(Json.object(nodes.get(index)).get("name"), project.get("name"))) { + return new ArrayList<>(list(Json.object(nodes.get(index)).get("children"))); + } + } + throw new PluginError("maven.unknownProject", "Maven reports no project at " + path + "."); + } + + private static boolean samePath(Path wanted, Path candidate) { + Path normalized = candidate.toAbsolutePath().normalize(); + if (normalized.equals(wanted)) { + return true; + } + try { + return normalized.toRealPath().equals(wanted.toRealPath()); + } catch (IOException error) { + return false; + } + } + + private static List list(Object value) { + List result = new ArrayList<>(); + if (value instanceof List) { + result.addAll((List) value); + } + return result; + } +} diff --git a/backend/src/main/java/io/github/litheidea/maven/backend/Snapshots.java b/backend/src/main/java/io/github/litheidea/maven/backend/Snapshots.java new file mode 100644 index 0000000..b818be7 --- /dev/null +++ b/backend/src/main/java/io/github/litheidea/maven/backend/Snapshots.java @@ -0,0 +1,109 @@ +package io.github.litheidea.maven.backend; + +import io.github.litheidea.plugin.sdk.Backend; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.StateStore; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * {@code maven-snapshot.json}: the last Maven result of each root, with the + * fingerprint of the inputs it was computed from. + * + *
+ * { "snapshotVersion": 1,
+ *   "roots": { "/abs/root": { "fingerprint", "savedAt", "model", "dependencies" } } }
+ * 
+ * + *

The {@link StateStore} replaces the file atomically. A file that cannot + * be read, has another {@code snapshotVersion} or lacks a field is a miss, + * never an error: the next run overwrites it. + */ +final class Snapshots { + static final String KEY = "maven-snapshot"; + static final int VERSION = 1; + /** Roots kept per workspace; the least recently saved go first. */ + private static final int MAX_ROOTS = 32; + + private final StateStore store; + private final Backend backend; + + Snapshots(StateStore store, Backend backend) { + this.store = store; + this.backend = backend; + } + + /** The stored result for {@code root}, or null when there is none for {@code fingerprint}. */ + synchronized MavenRuns.Loaded read(Path root, String fingerprint) { + Map entry = Json.object(roots(store.read(KEY, null)).get(root.toString())); + if (!fingerprint.equals(entry.get("fingerprint")) + || !(entry.get("model") instanceof Map) || !(entry.get("dependencies") instanceof Map)) { + return null; + } + return new MavenRuns.Loaded(Json.object(entry.get("model")), Json.object(entry.get("dependencies"))); + } + + synchronized void write(Path root, String fingerprint, MavenRuns.Loaded loaded) { + Map roots = roots(store.read(KEY, null)); + Map entry = new LinkedHashMap<>(); + entry.put("fingerprint", fingerprint); + entry.put("savedAt", System.currentTimeMillis()); + entry.put("model", loaded.model()); + entry.put("dependencies", loaded.dependencies()); + roots.remove(root.toString()); + roots.put(root.toString(), entry); + prune(roots); + Map snapshot = new LinkedHashMap<>(); + snapshot.put("snapshotVersion", VERSION); + snapshot.put("roots", roots); + try { + store.write(KEY, snapshot); + } catch (IOException | RuntimeException error) { + // The result is still answered; only the next start runs Maven again. + backend.log("Could not save the Maven snapshot: " + error.getMessage()); + } + } + + private static Map roots(Object stored) { + Map result = new LinkedHashMap<>(); + if (!(stored instanceof Map)) { + return result; + } + Object version = ((Map) stored).get("snapshotVersion"); + Object roots = ((Map) stored).get("roots"); + if (!(version instanceof Number) || ((Number) version).doubleValue() != VERSION || !(roots instanceof Map)) { + return result; + } + for (Map.Entry entry : ((Map) roots).entrySet()) { + if (entry.getKey() instanceof String && entry.getValue() instanceof Map) { + result.put((String) entry.getKey(), entry.getValue()); + } + } + return result; + } + + /** Drops roots that no longer exist, then the oldest beyond {@link #MAX_ROOTS}. */ + private static void prune(Map roots) { + roots.keySet().removeIf(root -> !Files.isDirectory(Paths.get(root))); + if (roots.size() <= MAX_ROOTS) { + return; + } + List> entries = new ArrayList<>(roots.entrySet()); + entries.sort(Comparator.comparingDouble(entry -> savedAt(entry.getValue()))); + for (int index = 0; index < entries.size() - MAX_ROOTS; index++) { + roots.remove(entries.get(index).getKey()); + } + } + + private static double savedAt(Object entry) { + Object value = Json.object(entry).get("savedAt"); + return value instanceof Number ? ((Number) value).doubleValue() : 0; + } +} diff --git a/io.github.lithe-idea.maven-build/plugin.json b/io.github.lithe-idea.maven-build/plugin.json index 759d7fe..e4d66cb 100644 --- a/io.github.lithe-idea.maven-build/plugin.json +++ b/io.github.lithe-idea.maven-build/plugin.json @@ -4,13 +4,13 @@ "displayName": "Maven", "version": "0.1.0", "pluginAPIVersion": 3, - "requires": { "buildSystems": 1 }, + "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", "signatureRequirement": "none" }, - "permissions": ["process.build"], + "permissions": ["languageServer.launch", "process.build"], "activationEvents": ["onBuildSystem:maven"], "languages": [], "buildSystems": [ @@ -87,18 +87,18 @@ "arguments": ["-DskipTests"] } ], - "model": { - "source": "command", - "arguments": [ - "-Dmaven.ext.class.path=${pluginDir}/lib/lithe-maven-extension.jar", - "-Dlithe.modelOutput=${outputFile}", - "pre-clean" - ], - "output": "outputFile", - "decoder": "litheBuildModel", + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "8", + "entry": "lib/lithe-maven-backend.jar" + }, + "arguments": ["--state", "${workspaceStateDir}"], "executesProjectCode": true, "mayUseNetwork": true }, + "model": { "source": "provider" }, "tasks": [ { "id": "clean", @@ -280,20 +280,7 @@ } ] }, - "dependencies": { - "source": "command", - "scopes": ["workspace", "project"], - "arguments": [ - "-Dmaven.ext.class.path=${pluginDir}/lib/lithe-maven-extension.jar", - "-Dlithe.dependenciesOutput=${outputFile}", - { "value": "-Dlithe.dependenciesProject=${project.path}", "when": "projectSelected" }, - "pre-clean" - ], - "output": "outputFile", - "decoder": "litheDependencies", - "executesProjectCode": true, - "mayUseNetwork": true - }, + "dependencies": { "source": "provider", "scopes": ["workspace", "project"] }, "sync": { "watch": [".mvn/extensions.xml", ".mvn/maven.config", "pom.xml"] }, "layout": { "excluded": ["target"] }, "languageImport": { diff --git a/scripts/backend-client.mjs b/scripts/backend-client.mjs new file mode 100644 index 0000000..139c47f --- /dev/null +++ b/scripts/backend-client.mjs @@ -0,0 +1,140 @@ +// Talks to lib/lithe-maven-backend.jar the way Lithe does: LSP base-protocol +// frames over the process's stdio, `initialize` offering litheBuild 1, then +// lithe/build/* requests. Every wait is bounded, and the backend runs in its +// own process group so a test can always end whatever it left behind. +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +export const WAIT_MS = 20_000; + +export class BackendClient { + #buffer = Buffer.alloc(0); + #pending = new Map(); + #nextId = 1; + stderr = ""; + + /** Starts `java -jar --state `; `javaOptions` go before `-jar`. */ + constructor({ java = "java", jar, state, env = process.env, javaOptions = [], timeoutMs = WAIT_MS }) { + this.timeoutMs = timeoutMs; + this.child = spawn(java, [...javaOptions, "-jar", jar, "--state", state], { + env, stdio: ["pipe", "pipe", "pipe"], detached: true, + }); + this.exited = new Promise((resolve) => this.child.once("exit", (code, signal) => { + this.#fail(`the backend exited with ${signal ?? `status ${code}`}`); + resolve(signal ?? code); + })); + this.child.once("error", (error) => this.#fail(`cannot start the backend: ${error.message}`)); + this.child.stdout.on("data", (chunk) => this.#read(chunk)); + this.child.stderr.on("data", (chunk) => { this.stderr += chunk; }); + this.child.stdin.on("error", () => {}); + } + + /** Sends a request; `response` settles with the whole response message. */ + send(method, params) { + const id = this.#nextId++; + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`no response to ${method} #${id} within ${this.timeoutMs} ms\nbackend stderr:\n${this.stderr}`)); + }, this.timeoutMs); + this.#pending.set(id, (message) => { + clearTimeout(timer); + if (message instanceof Error) reject(message); + else resolve(message); + }); + }); + this.#write({ jsonrpc: "2.0", id, method, params }); + return { id, response }; + } + + /** Sends a request and resolves with its result, or rejects with its error. */ + async request(method, params) { + const message = await this.send(method, params).response; + if (message.error) { + const error = new Error(`${method} failed: ${message.error.message}`); + error.response = message.error; + throw error; + } + return message.result; + } + + notify(method, params) { + this.#write({ jsonrpc: "2.0", method, params }); + } + + cancel(id) { + this.notify("$/cancelRequest", { id }); + } + + async initialize(root) { + const result = await this.request("initialize", { + processId: process.pid, + rootUri: pathToFileURL(root).href, + capabilities: { experimental: { litheBuild: { versions: [1] } } }, + }); + this.notify("initialized", {}); + return result; + } + + /** `shutdown` and `exit`; resolves with the exit code. */ + async close() { + if (this.child.exitCode === null && this.child.signalCode === null) { + await this.request("shutdown", null); + this.notify("exit", null); + this.child.stdin.end(); + } + return bounded(this.exited, "the backend to exit"); + } + + /** Ends the backend's whole process group, for teardown after a failure. */ + kill() { + if (!this.child.pid) return; + // macOS answers EPERM for a group whose members already exited but are not reaped yet. + try { process.kill(-this.child.pid, "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH" && error.code !== "EPERM") throw error; } + } + + #write(message) { + const body = Buffer.from(JSON.stringify(message), "utf8"); + this.child.stdin.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body])); + } + + /** Rejects every waiting request; nothing more will answer them. */ + #fail(reason) { + const error = new Error(`${reason}\nbackend stderr:\n${this.stderr}`); + for (const settle of this.#pending.values()) settle(error); + this.#pending.clear(); + } + + #read(chunk) { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + for (;;) { + const headerEnd = this.#buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const length = Number(/Content-Length: *(\d+)/i.exec(this.#buffer.subarray(0, headerEnd).toString("ascii"))?.[1]); + if (!Number.isSafeInteger(length)) return this.#broken("a frame without Content-Length"); + if (this.#buffer.length < headerEnd + 4 + length) return; + let message; + try { message = JSON.parse(this.#buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString("utf8")); } + catch { return this.#broken("a frame that is not JSON"); } + this.#buffer = this.#buffer.subarray(headerEnd + 4 + length); + if (message.id !== undefined && message.method === undefined) { + this.#pending.get(message.id)?.(message); + this.#pending.delete(message.id); + } + } + } + + #broken(what) { + this.#fail(`the backend wrote ${what}`); + this.kill(); + } +} + +export function bounded(promise, description, timeoutMs = WAIT_MS) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`timed out waiting for ${description}`)), timeoutMs); }), + ]).finally(() => clearTimeout(timer)); +} diff --git a/scripts/package.mjs b/scripts/package.mjs index 4f0a899..423bc68 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -2,29 +2,44 @@ // Packages the plugin into dist/--darwin.zip, the asset name // Lithe Store's release tracker recognizes (docs/publishing.md there). // -// The package is plain data plus one jar: the Maven core extension in -// extension/, built here with Maven and copied to lib/. It runs inside the -// user's own Maven, so one archive serves every Mac. The unpacked package -// stays in dist/package/ for installing a local development copy into Lithe. +// The package is plain data plus two jars, built here with Maven and copied +// to lib/: the Maven core extension in extension/, which runs inside the +// user's own Maven, and the build provider in backend/, which Lithe starts +// with Java 8 or newer. Neither carries native code, so one archive serves +// every Mac. The unpacked package stays in dist/package/ for installing a +// local development copy into Lithe. +// +// The backend shades Lithe's Java plugin SDK pinned in sdk.lock.json: the +// JAR is downloaded once into .cache/sdk/ and refused unless its SHA-256 +// matches. LITHE_SDK_JAR= uses a local JAR instead; while the lock has +// no SHA-256 (the SDK is not released yet) that is the only way, and a +// release (--expect-tag) refuses it. // // Usage: node scripts/package.mjs [--expect-tag ] import { mavenTimeout, runCommand, withInterrupts, ZIP_TIMEOUT_MS } from "./process.mjs"; import { createReport } from "./report.mjs"; import { createHash } from "node:crypto"; -import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; const PACKAGE_DIR = "io.github.lithe-idea.maven-build"; const ASSET_PREFIX = "maven-build"; const PLATFORM = "darwin"; const EXTENSION_JAR = "extension/target/lithe-maven-extension.jar"; +const BACKEND_JAR = "backend/target/lithe-maven-backend.jar"; +const SDK_DOWNLOAD_TIMEOUT_MS = 60_000; const PACKAGE_ROOT = join("dist", "package"); const manifest = JSON.parse(readFileSync(`${PACKAGE_DIR}/plugin.json`, "utf8")); const extensionPom = readFileSync("extension/pom.xml", "utf8"); -const extensionVersion = /lithe-maven-extension<\/artifactId>\s*(?:\s*)?([^<]+)<\/version>/s - .exec(extensionPom)?.[1]; +const pomVersion = (pom, artifact) => new RegExp(`${artifact}\\s*(?:\\s*)?([^<]+)`, "s") + .exec(pom)?.[1]; +const extensionVersion = pomVersion(extensionPom, "lithe-maven-extension"); +const backendPom = readFileSync("backend/pom.xml", "utf8"); +const backendVersion = pomVersion(backendPom, "lithe-maven-backend"); +const sdkLock = JSON.parse(readFileSync("sdk.lock.json", "utf8")); +const backendSdkVersion = /lithe-plugin-sdk-java<\/artifactId>\s*([^<]+)<\/version>/.exec(backendPom)?.[1]; const problems = []; if (manifest.id !== PACKAGE_DIR) problems.push(`plugin.json id ${manifest.id} must equal the directory ${PACKAGE_DIR}`); if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(manifest.version)) problems.push(`version ${manifest.version} is not MAJOR.MINOR.PATCH`); @@ -33,11 +48,20 @@ if (manifest.id === "dev.lithe" || manifest.id.startsWith("dev.lithe.")) problem if (extensionVersion !== manifest.version) { problems.push(`extension/pom.xml version ${extensionVersion} must equal plugin.json version ${manifest.version}`); } +if (backendVersion !== manifest.version) { + problems.push(`backend/pom.xml version ${backendVersion} must equal plugin.json version ${manifest.version}`); +} +if (backendSdkVersion !== sdkLock.version) { + problems.push(`backend/pom.xml SDK version ${backendSdkVersion} must equal sdk.lock.json version ${sdkLock.version}`); +} +if (sdkLock.sha256 !== null && !/^[0-9a-f]{64}$/.test(sdkLock.sha256)) problems.push("sdk.lock.json sha256 must be 64 lowercase hex digits or null"); const tagFlag = process.argv.indexOf("--expect-tag"); if (tagFlag >= 0) { const tag = process.argv[tagFlag + 1]; if (tag !== `v${manifest.version}`) problems.push(`tag ${tag} does not match plugin.json version ${manifest.version} (expected v${manifest.version})`); + if (sdkLock.sha256 === null) problems.push("a release needs a released SDK: pin its url and sha256 in sdk.lock.json"); + if (process.env.LITHE_SDK_JAR) problems.push("a release uses the SDK pinned in sdk.lock.json; unset LITHE_SDK_JAR"); } if (problems.length > 0) { for (const problem of problems) console.error(`✗ ${problem}`); @@ -46,6 +70,36 @@ if (problems.length > 0) { const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); +/** The SDK JAR to shade, checked against sdk.lock.json. */ +async function sdkJar(signal) { + const local = process.env.LITHE_SDK_JAR; + if (local) { + const path = resolve(local); + if (!existsSync(path)) throw new Error(`LITHE_SDK_JAR ${path} does not exist`); + const actual = sha256(path); + if (sdkLock.sha256 !== null && actual !== sdkLock.sha256) { + throw new Error(`LITHE_SDK_JAR ${path} has SHA-256 ${actual}, but sdk.lock.json pins ${sdkLock.sha256}`); + } + if (sdkLock.sha256 === null) console.warn(`! using the unreleased SDK ${path} (SHA-256 ${actual}); sdk.lock.json pins no release yet`); + return path; + } + if (sdkLock.sha256 === null) { + throw new Error(`SDK ${sdkLock.version} is not released yet (sdk.lock.json has no sha256). ` + + `Build it from ${sdkLock.source.repository} ${sdkLock.source.ref} and set LITHE_SDK_JAR to ${sdkLock.source.jar}.`); + } + const cached = resolve(".cache", "sdk", `lithe-plugin-sdk-java-${sdkLock.version}.jar`); + if (existsSync(cached) && sha256(cached) === sdkLock.sha256) return cached; + const response = await fetch(sdkLock.url, { signal: AbortSignal.any([signal, AbortSignal.timeout(SDK_DOWNLOAD_TIMEOUT_MS)]) }); + if (!response.ok) throw new Error(`downloading ${sdkLock.url} failed with HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== sdkLock.sha256) throw new Error(`${sdkLock.url} has SHA-256 ${actual}, but sdk.lock.json pins ${sdkLock.sha256}`); + mkdirSync(resolve(".cache", "sdk"), { recursive: true }); + writeFileSync(`${cached}.partial`, bytes); + renameSync(`${cached}.partial`, cached); + return cached; +} + /** Lithe refuses packages with symbolic links, so refuse them before zipping. */ function assertNoLinks(directory) { for (const name of readdirSync(directory)) { @@ -65,6 +119,14 @@ await withInterrupts(async (signal) => { if (!existsSync(EXTENSION_JAR)) throw new Error(`${EXTENSION_JAR} was not built`); }); + const sdk = await report.step("check the plugin SDK", () => sdkJar(signal)); + await report.step("build the Maven backend", async () => { + await runCommand(process.env.MVN ?? "mvn", [ + "-B", "-ntp", "-q", "-f", "backend/pom.xml", `-Dlithe.sdk.jar=${sdk}`, "clean", "package", + ], { timeoutMs, signal }); + if (!existsSync(BACKEND_JAR)) throw new Error(`${BACKEND_JAR} was not built`); + }); + await report.step("assemble the plugin package", () => { signal.throwIfAborted(); rmSync("dist", { recursive: true, force: true }); @@ -72,6 +134,7 @@ await withInterrupts(async (signal) => { cpSync(PACKAGE_DIR, packageRoot, { recursive: true }); mkdirSync(join(packageRoot, "lib"), { recursive: true }); cpSync(EXTENSION_JAR, join(packageRoot, "lib", "lithe-maven-extension.jar")); + cpSync(BACKEND_JAR, join(packageRoot, "lib", "lithe-maven-backend.jar")); cpSync("LICENSE", join(packageRoot, "LICENSE")); cpSync("NOTICE", join(packageRoot, "NOTICE")); assertNoLinks(packageRoot); diff --git a/scripts/test-backend.mjs b/scripts/test-backend.mjs new file mode 100644 index 0000000..aaa7cfa --- /dev/null +++ b/scripts/test-backend.mjs @@ -0,0 +1,319 @@ +#!/usr/bin/env node +// Drives lib/lithe-maven-backend.jar from dist/package over its stdio, as +// Lithe does, with test/fixtures/fake-maven-backend.mjs as Maven. The fake +// logs every run, so the checks count Maven runs exactly: the snapshot +// survives a restart, POM edits and refresh invalidate it, concurrent +// requests share one run, an edit during a run is not saved, and cancelling +// ends Maven and its process tree. No Maven or network is needed. +// +// Run scripts/package.mjs first. LITHE_TEST_JAVA selects the Java that runs +// the backend (default `java`), so CI also checks Java 8. +// +// Usage: node scripts/test-backend.mjs + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { BackendClient, bounded } from "./backend-client.mjs"; +import { withInterrupts } from "./process.mjs"; +import { createReport } from "./report.mjs"; + +const PACKAGE_DIR = "io.github.lithe-idea.maven-build"; +const jar = resolve("dist", "package", PACKAGE_DIR, "lib", "lithe-maven-backend.jar"); +if (!existsSync(jar)) { + console.error("✗ dist/package is missing the backend; run node scripts/package.mjs first"); + process.exit(1); +} +const java = process.env.LITHE_TEST_JAVA ?? "java"; +// `java -version` prints to stderr; Java 8 and older report 1.. +const [, first, second] = /version "(\d+)(?:\.(\d+))?/.exec(spawnSync(java, ["-version"], { encoding: "utf8" }).stderr) ?? []; +const javaMajor = Number(first) === 1 ? Number(second) : Number(first); +console.log(`backend runtime: ${java} (Java ${javaMajor})`); + +// Real paths: on macOS the temporary directory is reached through a link. +const scratch = realpathSync(mkdtempSync(join(tmpdir(), "lithe-maven-backend-test-"))); +const root = join(scratch, "reactor"); +const state = join(scratch, "state"); +const snapshotFile = join(state, "maven-snapshot.json"); +const controlFile = join(scratch, "control.json"); +const runLog = join(scratch, "runs.log"); +const fakeMaven = join(scratch, "bin", "mvn"); +const report = createReport("backend"); +const clients = new Set(); +let failed = false; + +const runs = () => existsSync(runLog) ? readFileSync(runLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)) : []; +const pids = (file) => existsSync(file) ? readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map(Number) : []; +const alive = (pid) => { + try { process.kill(pid, 0); return true; } + catch (error) { if (error.code === "ESRCH") return false; throw error; } +}; +const control = (value) => writeFileSync(controlFile, JSON.stringify(value)); +const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); +async function until(condition, description, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`); + await sleep(20); + } +} +const touch = (relative, text) => appendFileSync(join(root, relative), `\n\n`); +const params = (extra = {}) => ({ + buildSystemID: "maven", root: ".", options: {}, optionArguments: [], environment: {}, + tool: { executable: fakeMaven }, ...extra, +}); + +async function start() { + const client = new BackendClient({ + java, jar, state, + // A private home keeps the developer's ~/.m2/settings.xml out of the fingerprint. + javaOptions: [`-Duser.home=${join(scratch, "home")}`], + env: { ...process.env, FAKE_MAVEN_CONTROL: controlFile, FAKE_MAVEN_LOG: runLog }, + }); + clients.add(client); + const initialized = await client.initialize(root); + assert.deepEqual(initialized.capabilities.experimental.litheBuild, { version: 1 }); + return client; +} +async function stop(client) { + assert.equal(await client.close(), 0, `the backend exits cleanly\n${client.stderr}`); + clients.delete(client); +} +const check = async (name, body) => { + try { await report.step(name, body); } + catch (error) { failed = true; console.error(error.stack); } +}; + +async function test() { + try { + cpSync(resolve("test", "fixtures", "reactor"), root, { recursive: true }); + mkdirSync(join(scratch, "bin")); + mkdirSync(join(scratch, "home")); + const fixture = resolve("test", "fixtures", "fake-maven-backend.mjs"); + const wrapper = `#!/bin/sh\nFAKE_MAVEN_SELF="$0" exec ${JSON.stringify(process.execPath)} ${JSON.stringify(fixture)} "$@"\n`; + writeFileSync(fakeMaven, wrapper); + chmodSync(fakeMaven, 0o755); + control({}); + + let client = await start(); + + await check("the first request runs Maven once for the model and every module's dependencies", async () => { + const model = await client.request("lithe/build/model", params()); + assert.equal(model.format, "litheBuildModel"); + assert.deepEqual(model.projects.map((project) => project.name), ["reactor", "core", "app"]); + assert.equal(runs().length, 1); + const [run] = runs(); + assert.equal(run.cwd, root); + assert.ok(run.argv.includes("pre-clean") && run.argv.includes("-B") && run.argv.includes("-ntp")); + assert.ok(run.argv.some((arg) => arg.startsWith("-Dmaven.ext.class.path=") && arg.endsWith("lithe-maven-extension.jar"))); + + const app = await client.request("lithe/build/dependencies", params({ project: { name: "app", path: "app" } })); + assert.equal(app.format, "litheDependencies"); + assert.deepEqual(app.dependencies.map((node) => [node.group, node.name, node.scope]), [["demo", "core", "compile"]]); + const workspace = await client.request("lithe/build/dependencies", params({ project: null })); + assert.deepEqual(workspace.dependencies.map((node) => node.name), ["reactor", "core", "app"]); + const reactor = await client.request("lithe/build/dependencies", params({ project: { name: "reactor", path: "." } })); + assert.deepEqual(reactor.dependencies, []); + assert.equal(runs().length, 1, "dependencies come from the same run"); + + const snapshot = JSON.parse(readFileSync(snapshotFile, "utf8")).value; + assert.equal(snapshot.snapshotVersion, 1); + assert.deepEqual(Object.keys(snapshot.roots), [root]); + }); + + await check("an unknown project is an error, not an empty tree", async () => { + const { response } = client.send("lithe/build/dependencies", params({ project: { name: "nope", path: "nope" } })); + const message = await response; + assert.equal(message.error?.data?.code, "maven.unknownProject"); + }); + + await check("a restarted backend answers from the snapshot without running Maven", async () => { + await stop(client); + client = await start(); + const model = await client.request("lithe/build/model", params()); + assert.equal(model.fakeRun, 1); + const app = await client.request("lithe/build/dependencies", params({ project: { name: "app", path: "app" } })); + assert.equal(app.dependencies[0].name, "core"); + assert.equal(runs().length, 1); + }); + + await check("editing a module's POM invalidates the snapshot", async () => { + touch("app/pom.xml", "edited"); + assert.equal((await client.request("lithe/build/model", params())).fakeRun, 2); + assert.equal((await client.request("lithe/build/model", params())).fakeRun, 2); + assert.equal(runs().length, 2); + }); + + await check("refresh runs Maven even when the snapshot is current", async () => { + assert.equal((await client.request("lithe/build/model", params({ refresh: true }))).fakeRun, 3); + assert.equal((await client.request("lithe/build/model", params())).fakeRun, 3); + assert.equal(runs().length, 3); + }); + + await check("changed options and .mvn/maven.config invalidate the snapshot", async () => { + assert.equal((await client.request("lithe/build/model", params({ optionArguments: ["-o"] }))).fakeRun, 4); + assert.ok(runs().at(-1).argv.includes("-o"), "option arguments reach Maven"); + mkdirSync(join(root, ".mvn")); + writeFileSync(join(root, ".mvn", "maven.config"), "-Pci\n"); + assert.equal((await client.request("lithe/build/model", params({ optionArguments: ["-o"] }))).fakeRun, 5); + assert.equal((await client.request("lithe/build/model", params({ optionArguments: ["-o"] }))).fakeRun, 5); + }); + + await check("concurrent requests share one Maven run", async () => { + touch("core/pom.xml", "concurrent"); + const gate = join(scratch, "gate-concurrent"); + control({ gate }); + const before = runs().length; + const model = client.send("lithe/build/model", params()); + const app = client.send("lithe/build/dependencies", params({ project: { name: "app", path: "app" } })); + await until(() => existsSync(`${gate}.started`), "Maven to start"); + await sleep(300); + writeFileSync(gate, ""); + const [modelResponse, appResponse] = await Promise.all([model.response, app.response]); + assert.ok(modelResponse.result && appResponse.result, JSON.stringify([modelResponse, appResponse])); + assert.equal(runs().length, before + 1); + control({}); + }); + + await check("a POM edited while Maven runs is answered but not saved", async () => { + const gate = join(scratch, "gate-edit"); + control({ gate }); + const saved = readFileSync(snapshotFile, "utf8"); + const pending = client.send("lithe/build/model", params({ refresh: true })); + await until(() => existsSync(`${gate}.started`), "Maven to start"); + touch("core/pom.xml", "edited during the run"); + writeFileSync(gate, ""); + const response = await pending.response; + assert.equal(response.result?.fakeRun, runs().length); + assert.equal(readFileSync(snapshotFile, "utf8"), saved, "the snapshot file is unchanged"); + control({}); + const before = runs().length; + await client.request("lithe/build/model", params()); + assert.equal(runs().length, before + 1, "the edit is read by a new run"); + await client.request("lithe/build/model", params()); + assert.equal(runs().length, before + 1, "and that run is saved"); + }); + + await check("Maven goes on while another request still waits for it", async () => { + const gate = join(scratch, "gate-partial-cancel"); + control({ gate }); + touch("app/pom.xml", "partial cancel"); + const before = runs().length; + const first = client.send("lithe/build/model", params()); + const second = client.send("lithe/build/dependencies", params({ project: { name: "core", path: "core" } })); + await until(() => existsSync(`${gate}.started`), "Maven to start"); + const maven = runs().at(-1).pid; + client.cancel(first.id); + assert.equal((await first.response).error?.code, -32800); + await sleep(300); + assert.ok(alive(maven), "Maven still runs for the second request"); + writeFileSync(gate, ""); + const result = (await second.response).result; + assert.deepEqual(result.dependencies.map((node) => node.name), ["junit"]); + assert.equal(runs().length, before + 1); + control({}); + }); + + await check("cancelling every waiting request ends Maven and its process tree", async () => { + const gate = join(scratch, "gate-cancel"); + control({ gate, child: true }); + touch("app/pom.xml", "cancel"); + const saved = readFileSync(snapshotFile, "utf8"); + const first = client.send("lithe/build/model", params()); + const second = client.send("lithe/build/model", params()); + await until(() => existsSync(`${gate}.started`) && pids(`${runLog}.children`).length > 0, "Maven and its child to start"); + const maven = runs().at(-1).pid; + const [child] = pids(`${runLog}.children`); + client.cancel(first.id); + client.cancel(second.id); + assert.equal((await first.response).error?.code, -32800); + assert.equal((await second.response).error?.code, -32800); + await until(() => !alive(maven), "Maven to end", 8_000); + if (javaMajor >= 9) { + await until(() => !alive(child), "Maven's child to end", 8_000); + } else { + // Java 8 cannot list descendants; Lithe's process scope ends them with the backend. + console.log(` Java ${javaMajor}: only Maven itself is stopped; ending its child here`); + try { process.kill(child, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + assert.equal(readFileSync(snapshotFile, "utf8"), saved, "a cancelled run saves nothing"); + control({}); + rmSync(`${runLog}.children`); + }); + + await check("a failed Maven run reports Maven's errors and keeps the old snapshot", async () => { + control({ exit: 1 }); + const saved = readFileSync(snapshotFile, "utf8"); + const { response } = client.send("lithe/build/model", params({ refresh: true })); + const { error } = await response; + assert.equal(error?.data?.code, "maven.loadFailed"); + assert.match(error.message, /\[ERROR\] fake failure for the backend test/); + assert.doesNotMatch(error.message, /Scanning for projects/); + assert.equal(readFileSync(snapshotFile, "utf8"), saved); + control({}); + }); + + await check("Maven that succeeds without readable documents is an error, not an empty model", async () => { + const saved = readFileSync(snapshotFile, "utf8"); + for (const output of ["missing", "malformed"]) { + control({ output }); + const { error } = await client.send("lithe/build/model", params({ refresh: true })).response; + assert.equal(error?.data?.code, "maven.invalidOutput", output); + } + assert.equal(readFileSync(snapshotFile, "utf8"), saved); + control({}); + }); + + await check("a corrupt snapshot or one of an unknown version is a miss", async () => { + await client.request("lithe/build/model", params()); + let before = runs().length; + await stop(client); + writeFileSync(snapshotFile, "{not JSON"); + client = await start(); + await client.request("lithe/build/model", params()); + assert.equal(runs().length, before + 1); + + const stored = JSON.parse(readFileSync(snapshotFile, "utf8")); + stored.value.snapshotVersion = 2; + writeFileSync(snapshotFile, JSON.stringify(stored)); + before = runs().length; + await client.request("lithe/build/model", params()); + assert.equal(runs().length, before + 1); + assert.equal(JSON.parse(readFileSync(snapshotFile, "utf8")).value.snapshotVersion, 1, "the next run rewrites it"); + }); + + await check("without tool.executable the backend uses the project's Maven Wrapper", async () => { + mkdirSync(join(root, ".mvn", "wrapper"), { recursive: true }); + writeFileSync(join(root, ".mvn", "wrapper", "maven-wrapper.properties"), "distributionUrl=https://example.invalid/maven.zip\n"); + writeFileSync(join(root, "mvnw"), readFileSync(fakeMaven)); + chmodSync(join(root, "mvnw"), 0o755); + const { tool, ...rest } = params(); + await client.request("lithe/build/model", rest); + assert.equal(runs().at(-1).self, join(root, "mvnw")); + }); + + await check("the backend exits on shutdown and leaves no Maven behind", async () => { + await stop(client); + for (const { pid } of runs()) assert.ok(!alive(pid), `Maven ${pid} ended`); + }); + } catch (error) { + failed = true; + report.failure("backend lane failed", error); + console.error(error.stack); + } finally { + for (const client of clients) { + if (failed) console.error(`backend stderr:\n${client.stderr}`); + client.kill(); + await bounded(client.exited, "the backend to be killed").catch(() => {}); + } + for (const pid of [...runs().map((run) => run.pid), ...pids(`${runLog}.children`)]) { + try { process.kill(pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + try { await report.step("remove the scratch workspace", () => rmSync(scratch, { recursive: true, force: true })); } + finally { report.write(); } + } + process.exitCode = failed ? 1 : 0; +} +await withInterrupts(test); diff --git a/scripts/test-process.mjs b/scripts/test-process.mjs index 7b95d0c..171eed8 100644 --- a/scripts/test-process.mjs +++ b/scripts/test-process.mjs @@ -90,7 +90,7 @@ await withInterrupts(async (signal) => { assertGone(records, 3); })); } - await check("missing and malformed command output fail both the lane and its reports", async () => { + await check("a provider that cannot start or answers garbage fails both the lane and its reports", async () => { const cwd = join(scratch, "invalid-output"); mkdirSync(cwd); mkdirSync(join(cwd, "test/fixtures/reactor"), { recursive: true }); @@ -99,15 +99,16 @@ await withInterrupts(async (signal) => { mkdirSync(join(plugin, "lib"), { recursive: true }); cpSync(join(packageId, "plugin.json"), join(plugin, "plugin.json")); writeFileSync(join(plugin, "lib/lithe-maven-extension.jar"), "test placeholder"); - const fake = join(cwd, "fake-maven.mjs"); - cpSync(resolve("test/fixtures/fake-maven.mjs"), fake); + writeFileSync(join(plugin, "lib/lithe-maven-backend.jar"), "test placeholder"); + const fake = join(cwd, "fake-java.mjs"); + cpSync(resolve("test/fixtures/fake-java.mjs"), fake); chmodSync(fake, 0o755); const temporary = join(cwd, "tmp"); mkdirSync(temporary); - for (const mode of ["missing", "malformed"]) { + for (const mode of ["exit", "malformed"]) { const reports = join(cwd, mode); await assert.rejects(runCommand(process.execPath, [resolve("scripts/test.mjs")], { - cwd, env: { ...process.env, MVN: fake, FAKE_MAVEN_MODE: mode, TMPDIR: temporary, LITHE_REPORT_DIR: reports }, + cwd, env: { ...process.env, LITHE_TEST_JAVA: fake, FAKE_JAVA_MODE: mode, TMPDIR: temporary, LITHE_REPORT_DIR: reports }, stdio: "ignore", timeoutMs: 5_000, graceMs: 4_000, }), /status 1/); const result = JSON.parse(readFileSync(join(reports, "maven-integration.json"), "utf8")); diff --git a/scripts/test.mjs b/scripts/test.mjs index 4f114d8..a14fee1 100644 --- a/scripts/test.mjs +++ b/scripts/test.mjs @@ -1,10 +1,14 @@ #!/usr/bin/env node -// Runs the commands plugin.json declares against test/fixtures/reactor with -// a real Maven, and checks the documents the extension writes. Lithe's own -// tests decode these documents; this script makes sure Maven produces them. +// Runs test/fixtures/reactor with a real Maven: the model and dependency +// trees through the packaged build provider (lib/lithe-maven-backend.jar, +// which runs Maven with the extension), and the commands plugin.json +// declares. It checks the documents Lithe receives; Lithe's own tests decode +// them, this script makes sure Maven produces them. The provider's caching is +// covered by scripts/test-backend.mjs with a fake Maven. // // Run scripts/package.mjs first. MVN selects the Maven executable (default -// `mvn`), so CI can also check the oldest supported Maven. +// `mvn`), so CI can also check the oldest supported Maven; LITHE_TEST_JAVA +// the Java that runs the provider (default `java`). // // LITHE_CAPTURE_DIR=

also copies the documents there, with the // scratch workspace written as `${workspace}`, for Lithe's Core tests. @@ -12,6 +16,8 @@ // Usage: node scripts/test.mjs import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { BackendClient, bounded } from "./backend-client.mjs"; import { mavenTimeout, runCommand, withInterrupts } from "./process.mjs"; import { createReport } from "./report.mjs"; import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; @@ -21,7 +27,7 @@ import { join, resolve } from "node:path"; const PACKAGE_DIR = "io.github.lithe-idea.maven-build"; const pluginDir = resolve("dist", "package", PACKAGE_DIR); const maven = process.env.MVN ?? "mvn"; -if (!existsSync(join(pluginDir, "lib", "lithe-maven-extension.jar"))) { +if (!existsSync(join(pluginDir, "lib", "lithe-maven-extension.jar")) || !existsSync(join(pluginDir, "lib", "lithe-maven-backend.jar"))) { console.error("✗ dist/package is missing; run node scripts/package.mjs first"); process.exit(1); } @@ -33,7 +39,7 @@ const buildSystem = manifest.buildSystems[0]; * option has a value, so `${optionArguments:…}` contributes nothing, and a * conditional argument is kept when its conditions hold. */ -function expand(list, { outputFile, project, suite }) { +function expand(list, { project, suite }) { const conditions = { projectSelected: project !== undefined, noProjectSelected: project === undefined, @@ -51,7 +57,6 @@ function expand(list, { outputFile, project, suite }) { result.push( value .replaceAll("${pluginDir}", pluginDir) - .replaceAll("${outputFile}", outputFile ?? "") .replaceAll("${project.path}", project ?? "") .replaceAll("${test.suite}", suite ?? ""), ); @@ -68,12 +73,12 @@ function run(name, root, list, signal, values = {}) { })); } -const scratch = mkdtempSync(join(tmpdir(), "lithe-maven-test-")); +const scratch = realpathSync(mkdtempSync(join(tmpdir(), "lithe-maven-test-"))); const root = join(scratch, "reactor"); const captureDir = process.env.LITHE_CAPTURE_DIR; -function capture(name, file) { +function capture(name, document) { if (!captureDir) return; - let text = readFileSync(file, "utf8"); + let text = JSON.stringify(document); // The real path first: on macOS it extends the temporary path. for (const prefix of new Set([realpathSync(root), root])) text = text.replaceAll(prefix, "${workspace}"); mkdirSync(captureDir, { recursive: true }); @@ -89,14 +94,34 @@ const check = async (name, body) => { } }; +/** The executable Lithe would pass as tool.executable: MVN, or `mvn` on PATH. */ +function mavenExecutable() { + if (maven.includes("/")) return resolve(maven); + const found = spawnSync("/bin/sh", ["-c", 'command -v "$0"', maven], { encoding: "utf8" }).stdout.trim(); + if (!found) throw new Error(`${maven} is not on PATH`); + return found; +} + async function test(signal) { + let backend; + const stopBackend = () => backend?.kill(); + signal.addEventListener("abort", stopBackend, { once: true }); try { cpSync(resolve("test", "fixtures", "reactor"), root, { recursive: true }); - const modelFile = join(scratch, "model.json"); - await run("read the reactor model", root, buildSystem.model.arguments, signal, { outputFile: modelFile }); - capture("model.json", modelFile); - const model = JSON.parse(readFileSync(modelFile, "utf8")); + backend = new BackendClient({ + java: process.env.LITHE_TEST_JAVA ?? "java", + jar: join(pluginDir, "lib", "lithe-maven-backend.jar"), + state: join(scratch, "state"), + timeoutMs, + }); + await report.step("start the build provider", () => backend.initialize(root)); + const params = (extra = {}) => ({ + buildSystemID: "maven", root: ".", options: {}, optionArguments: [], environment: {}, + tool: { executable: mavenExecutable() }, ...extra, + }); + const model = await report.step("read the reactor model", () => backend.request("lithe/build/model", params())); + capture("model.json", model); const byName = (name) => model.projects.find((project) => project.name === name); await check("the model lists the reactor and its aggregator tree", () => { @@ -138,26 +163,35 @@ async function test(signal) { assert.ok(!model.contentRoots.some((entry) => entry.project === byName("reactor").path)); }); - const projectDependencies = join(scratch, "app-dependencies.json"); - await run("read project dependencies", root, buildSystem.dependencies.arguments, signal, { outputFile: projectDependencies, project: "app" }); + const projectDependencies = await report.step("read project dependencies", () => backend.request( + "lithe/build/dependencies", params({ project: { name: "app", path: "app" } }), + )); capture("app-dependencies.json", projectDependencies); await check("a project's tree resolves an uninstalled sibling from the reactor", () => { - const tree = JSON.parse(readFileSync(projectDependencies, "utf8")); + const tree = projectDependencies; assert.equal(tree.format, "litheDependencies"); assert.deepEqual(tree.dependencies.map((node) => [node.group, node.name, node.scope]), [["demo", "core", "compile"]]); }); - const workspaceDependencies = join(scratch, "dependencies.json"); - await run("read workspace dependencies", root, buildSystem.dependencies.arguments, signal, { outputFile: workspaceDependencies }); + const workspaceDependencies = await report.step("read workspace dependencies", () => backend.request( + "lithe/build/dependencies", params({ project: null }), + )); capture("dependencies.json", workspaceDependencies); await check("the workspace tree has one node per project with transitive children", () => { - const tree = JSON.parse(readFileSync(workspaceDependencies, "utf8")); + const tree = workspaceDependencies; assert.deepEqual(tree.dependencies.map((node) => node.name), ["reactor", "core", "app"]); const junit = tree.dependencies[1].children[0]; assert.deepEqual([junit.name, junit.version, junit.scope], ["junit", "4.13.2", "test"]); assert.equal(junit.children[0].name, "hamcrest-core"); }); + await check("one Maven run serves the model and every tree, and is saved for the next start", async () => { + const saved = JSON.parse(readFileSync(join(scratch, "state", "maven-snapshot.json"), "utf8")).value; + assert.equal(saved.snapshotVersion, 1); + assert.deepEqual(saved.roots[root].model, model); + assert.equal(await backend.close(), 0); + }); + await check("reading the model and dependencies builds nothing", () => { for (const directory of ["target", "core/target", "app/target"]) { assert.ok(!existsSync(join(root, directory)), `${directory} was not created`); @@ -180,7 +214,13 @@ async function test(signal) { failed = true; report.failure("integration lane failed", error); console.error(error.stack); + if (backend) console.error(`build provider stderr:\n${backend.stderr}`); } finally { + signal.removeEventListener("abort", stopBackend); + if (backend) { + backend.kill(); + await bounded(backend.exited, "the build provider to end").catch(() => {}); + } try { await report.step("remove the scratch reactor", () => rmSync(scratch, { recursive: true, force: true })); } finally { report.write(); } } diff --git a/scripts/verify-scripts.mjs b/scripts/verify-scripts.mjs index 71f4a5c..9e9e380 100644 --- a/scripts/verify-scripts.mjs +++ b/scripts/verify-scripts.mjs @@ -39,10 +39,10 @@ const unchanged = (name) => { await withInterrupts(async (signal) => { try { - for (const path of [packageId, "scripts", "extension", "test", "LICENSE", "NOTICE"]) { + for (const path of [packageId, "scripts", "extension", "backend", "sdk.lock.json", "test", "LICENSE", "NOTICE"]) { cpSync(resolve(path), join(scratch, path), { recursive: true, - filter: (source) => source !== resolve("extension/target"), + filter: (source) => source !== resolve("extension/target") && source !== resolve("backend/target"), }); } const timeoutMs = mavenTimeout(); diff --git a/sdk.lock.json b/sdk.lock.json new file mode 100644 index 0000000..29d1d8f --- /dev/null +++ b/sdk.lock.json @@ -0,0 +1,12 @@ +{ + "comment": "Lithe Java plugin SDK shaded into lib/lithe-maven-backend.jar. scripts/package.mjs downloads url and refuses a JAR whose SHA-256 differs. sha256 stays null until 1lck/Lithe-IDEA tags sdk-java-v0.1.0; until then set LITHE_SDK_JAR to a JAR built from source.ref (README, Development).", + "version": "0.1.0", + "url": "https://github.com/1lck/Lithe-IDEA/releases/download/sdk-java-v0.1.0/lithe-plugin-sdk-java-0.1.0.jar", + "sha256": null, + "source": { + "repository": "https://github.com/1lck/Lithe-IDEA", + "ref": "feat/1351-java-sdk", + "pom": "sdk/java/pom.xml", + "jar": "sdk/java/plugin-sdk/target/lithe-plugin-sdk-java-0.1.0.jar" + } +} diff --git a/test/fixtures/fake-java.mjs b/test/fixtures/fake-java.mjs new file mode 100644 index 0000000..120b216 --- /dev/null +++ b/test/fixtures/fake-java.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +// Stands in for the Java that runs the build provider, so the integration +// lane's failure reporting is checked without Java or Maven: +// FAKE_JAVA_MODE=exit the provider cannot start +// FAKE_JAVA_MODE=malformed the provider answers with a frame that is not JSON +if (process.env.FAKE_JAVA_MODE === "exit") { + console.error("fake java: cannot start the provider"); + process.exit(3); +} +process.stdout.write("Content-Length: 5\r\n\r\n{nope"); +setInterval(() => {}, 60_000); diff --git a/test/fixtures/fake-maven-backend.mjs b/test/fixtures/fake-maven-backend.mjs new file mode 100644 index 0000000..ebcff90 --- /dev/null +++ b/test/fixtures/fake-maven-backend.mjs @@ -0,0 +1,102 @@ +// Stands in for `mvn` when scripts/test-backend.mjs drives the backend. It +// logs each run, writes the two documents the extension would write for the +// reactor in its working directory, and obeys a control file the test +// rewrites between steps: +// { "gate": "" } write .started, then wait until exists +// { "child": true } start a SIGTERM-ignoring child and log its PID +// { "exit": } print an [ERROR] line and exit with the status +// { "output": "missing" | "malformed" } succeed without the documents, or with invalid JSON +import { spawn } from "node:child_process"; +import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const control = existsSync(process.env.FAKE_MAVEN_CONTROL ?? "") + ? JSON.parse(readFileSync(process.env.FAKE_MAVEN_CONTROL, "utf8")) + : {}; +const log = process.env.FAKE_MAVEN_LOG; +const argv = process.argv.slice(2); +appendFileSync(log, `${JSON.stringify({ pid: process.pid, self: process.env.FAKE_MAVEN_SELF, cwd: process.cwd(), argv })}\n`); + +if (control.child) { + const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 60_000)"], { stdio: "ignore" }); + appendFileSync(`${log}.children`, `${child.pid}\n`); +} +if (control.gate) { + writeFileSync(`${control.gate}.started`, `${process.pid}\n`); + const deadline = Date.now() + 30_000; + while (!existsSync(control.gate)) { + if (Date.now() > deadline) process.exit(97); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} +if (control.exit) { + console.log("[INFO] Scanning for projects..."); + console.log("[ERROR] fake failure for the backend test"); + process.exit(control.exit); +} + +const value = (name) => argv.find((arg) => arg.startsWith(`-D${name}=`))?.slice(name.length + 3); +if (value("lithe.dependenciesProject") !== undefined) throw new Error("the backend must export every module"); +const modelOutput = value("lithe.modelOutput"); +const dependenciesOutput = value("lithe.dependenciesOutput"); +if (!modelOutput || !dependenciesOutput) throw new Error("expected both output properties"); +if (control.output === "missing") process.exit(0); +if (control.output === "malformed") { + writeFileSync(modelOutput, "{invalid JSON"); + writeFileSync(dependenciesOutput, "{invalid JSON"); + process.exit(0); +} + +const tag = (xml, name) => new RegExp(`<${name}>([^<]*)`).exec(xml)?.[1]; +const withoutParent = (xml) => xml.replace(/[\s\S]*?<\/parent>/, "").replace(/[\s\S]*<\/dependencies>/, ""); +function read(directory, parent) { + const xml = readFileSync(join(directory, "pom.xml"), "utf8"); + const own = withoutParent(xml); + const project = { + groupId: tag(own, "groupId") ?? parent?.groupId, + artifactId: tag(own, "artifactId"), + version: tag(own, "version") ?? parent?.version, + packaging: tag(own, "packaging") ?? "jar", + path: directory, + displayName: tag(own, "name"), + parentPath: parent?.path, + }; + project.dependencies = [...xml.matchAll(/([\s\S]*?)<\/dependency>/g)].map(([, body]) => ({ + name: tag(body, "artifactId"), + group: tag(body, "groupId"), + version: tag(body, "version").replace("${project.version}", project.version), + scope: tag(body, "scope") ?? "compile", + properties: { type: "jar" }, + children: [], + })); + const modules = [...xml.matchAll(/([^<]+)<\/module>/g)].map(([, name]) => name); + return [project, ...modules.flatMap((name) => read(join(directory, name), project))]; +} +const projects = read(process.cwd()); +const run = readFileSync(log, "utf8").trim().split("\n").length; + +writeFileSync(modelOutput, JSON.stringify({ + format: "litheBuildModel", + version: 1, + fakeRun: run, + projects: projects.map((project) => ({ + name: project.artifactId, + path: project.path, + ...(project.displayName ? { displayName: project.displayName } : {}), + ...(project.parentPath ? { parent: project.parentPath } : {}), + properties: { groupId: project.groupId, artifactId: project.artifactId, version: project.version, packaging: project.packaging }, + })), + tasks: [], + contentRoots: [], +})); +writeFileSync(dependenciesOutput, JSON.stringify({ + format: "litheDependencies", + version: 1, + dependencies: projects.map((project) => ({ + name: project.artifactId, + group: project.groupId, + version: project.version, + properties: { kind: "project", packaging: project.packaging }, + children: project.dependencies, + })), +})); diff --git a/test/fixtures/fake-maven.mjs b/test/fixtures/fake-maven.mjs deleted file mode 100644 index b5666fb..0000000 --- a/test/fixtures/fake-maven.mjs +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env node -// Deterministic command failures for the integration script's reporting paths. -import { writeFileSync } from "node:fs"; -if (process.env.FAKE_MAVEN_MODE === "missing") process.exit(0); -const output = process.argv.find((arg) => arg.startsWith("-Dlithe.modelOutput=")); -if (!output) throw new Error("expected a model output argument"); -writeFileSync(output.slice("-Dlithe.modelOutput=".length), "{invalid JSON");