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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ This document is intended for Spotless developers.
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`).

## [Unreleased]
### Added
- Add `dependencyOverrides` to the npm-based formatter steps (`PrettierFormatterStep`, `EslintFormatterStep`, `TsFmtFormatterStep`), written to the npm `overrides` field of the generated `package.json` to pin transitive dependencies. ([#2534](https://github.com/diffplug/spotless/issues/2534))
### Fixed
- GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,21 @@ public static Map<String, String> defaultDevDependenciesWithEslint(String versio
}

public static FormatterStep create(Map<String, String> devDependencies, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) {
return create(devDependencies, Map.of(), provisioner, projectDir, buildDir, cacheDir, npmPathResolver, eslintConfig);
}

/**
* @param dependencyOverrides written to the npm {@code overrides} field of the generated {@code package.json},
* e.g. to pin a transitive dependency to an exact version
*/
public static FormatterStep create(Map<String, String> devDependencies, Map<String, String> dependencyOverrides, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) {
requireNonNull(devDependencies);
requireNonNull(dependencyOverrides);
requireNonNull(provisioner);
requireNonNull(projectDir);
requireNonNull(buildDir);
return FormatterStep.createLazy(NAME,
() -> new State(NAME, devDependencies, projectDir, buildDir, cacheDir, npmPathResolver, eslintConfig),
() -> new State(NAME, devDependencies, dependencyOverrides, projectDir, buildDir, cacheDir, npmPathResolver, eslintConfig),
State::createFormatterFunc);
}

Expand All @@ -83,12 +92,13 @@ private static class State extends NpmFormatterStepStateBase implements Serializ
private final EslintConfig origEslintConfig;
private EslintConfig eslintConfigInUse;

State(String stepName, Map<String, String> devDependencies, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) throws IOException {
State(String stepName, Map<String, String> devDependencies, Map<String, String> dependencyOverrides, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, EslintConfig eslintConfig) throws IOException {
super(stepName,
new NpmConfig(
replaceDevDependencies(
replaceDependencies(
NpmResourceHelper.readUtf8StringFromClasspath(EslintFormatterStep.class, "/com/diffplug/spotless/npm/eslint-package.json.template"),
new TreeMap<>(devDependencies)),
new TreeMap<>(devDependencies),
dependencyOverrides),
NpmResourceHelper.readUtf8StringFromClasspath(EslintFormatterStep.class,
"/com/diffplug/spotless/npm/common-serve.js",
"/com/diffplug/spotless/npm/eslint-serve.js"),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2016-2025 DiffPlug
* Copyright 2016-2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,6 +25,8 @@
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.StringJoiner;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
Expand Down Expand Up @@ -150,6 +152,32 @@ protected ServerProcessInfo npmRunServer() throws ServerStartException, IOExcept
}

protected static String replaceDevDependencies(String template, Map<String, String> devDependencies) {
return replaceDependencies(template, devDependencies, Map.of());
}

/**
* Renders the {@code package.json} template.
*
* @param dependencyOverrides written to the npm <a href="https://docs.npmjs.com/cli/configuring-npm/package-json#overrides">{@code overrides}</a>
* field, sorted by name. When empty, no {@code overrides} field is written, so the result is identical
* to the one of earlier versions (and existing {@code node_modules} directories stay valid).
*/
protected static String replaceDependencies(String template, Map<String, String> devDependencies, Map<String, String> dependencyOverrides) {
return replacePlaceholders(template, Map.of(
"devDependencies", devDependenciesBlock(devDependencies),
"dependencyOverrides", dependencyOverridesBlock(dependencyOverrides)));
}

private static String dependencyOverridesBlock(Map<String, String> dependencyOverrides) {
if (dependencyOverrides.isEmpty()) {
return "";
}
StringJoiner entries = new StringJoiner(",\n", "\n\t\"overrides\": {\n", "\n\t},");
new TreeMap<>(dependencyOverrides).forEach((name, spec) -> entries.add("\t\t" + JsonEscaper.jsonEscape(name) + ": " + JsonEscaper.jsonEscape(spec)));
return entries.toString();
}

private static String devDependenciesBlock(Map<String, String> devDependencies) {
StringBuilder builder = new StringBuilder();
Iterator<Map.Entry<String, String>> entryIter = devDependencies.entrySet().iterator();
while (entryIter.hasNext()) {
Expand All @@ -163,13 +191,14 @@ protected static String replaceDevDependencies(String template, Map<String, Stri
builder.append(",\n");
}
}
return replacePlaceholders(template, Map.of("devDependencies", builder.toString()));
return builder.toString();
}

private static String replacePlaceholders(String template, Map<String, String> replacements) {
String result = template;
for (Entry<String, String> entry : replacements.entrySet()) {
result = result.replaceAll("\\Q${" + entry.getKey() + "}\\E", entry.getValue());
// literal replacement: values may contain '$' (e.g. npm override references like "$prettier")
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,20 @@ public static Map<String, String> defaultDevDependenciesWithPrettier(String vers
}

public static FormatterStep create(Map<String, String> devDependencies, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) {
return create(devDependencies, Map.of(), provisioner, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig);
}

/**
* @param dependencyOverrides written to the npm {@code overrides} field of the generated {@code package.json},
* e.g. to pin a transitive dependency of a prettier plugin to an exact version
*/
public static FormatterStep create(Map<String, String> devDependencies, Map<String, String> dependencyOverrides, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) {
requireNonNull(devDependencies);
requireNonNull(dependencyOverrides);
requireNonNull(provisioner);
requireNonNull(buildDir);
return FormatterStep.createLazy(NAME,
() -> new State(NAME, devDependencies, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig),
() -> new State(NAME, devDependencies, dependencyOverrides, projectDir, buildDir, cacheDir, npmPathResolver, prettierConfig),
State::createFormatterFunc);
}

Expand All @@ -64,12 +73,13 @@ private static class State extends NpmFormatterStepStateBase implements Serializ
private static final long serialVersionUID = -539537027004745812L;
private final PrettierConfig prettierConfig;

State(String stepName, Map<String, String> devDependencies, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) throws IOException {
State(String stepName, Map<String, String> devDependencies, Map<String, String> dependencyOverrides, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, PrettierConfig prettierConfig) throws IOException {
super(stepName,
new NpmConfig(
replaceDevDependencies(
replaceDependencies(
NpmResourceHelper.readUtf8StringFromClasspath(PrettierFormatterStep.class, "/com/diffplug/spotless/npm/prettier-package.json.template"),
new TreeMap<>(devDependencies)),
new TreeMap<>(devDependencies),
dependencyOverrides),
NpmResourceHelper.readUtf8StringFromClasspath(PrettierFormatterStep.class,
"/com/diffplug/spotless/npm/common-serve.js",
"/com/diffplug/spotless/npm/prettier-serve.js"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,19 @@ public final class TsFmtFormatterStep {
public static final String NAME = "tsfmt-format";

public static FormatterStep create(Map<String, String> versions, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map<String, Object> inlineTsFmtSettings) {
return create(versions, Map.of(), provisioner, projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings);
}

/**
* @param dependencyOverrides written to the npm {@code overrides} field of the generated {@code package.json},
* e.g. to pin a transitive dependency to an exact version
*/
public static FormatterStep create(Map<String, String> versions, Map<String, String> dependencyOverrides, Provisioner provisioner, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map<String, Object> inlineTsFmtSettings) {
requireNonNull(dependencyOverrides);
requireNonNull(provisioner);
requireNonNull(buildDir);
return FormatterStep.createLazy(NAME,
() -> new State(NAME, versions, projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings),
() -> new State(NAME, versions, dependencyOverrides, projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings),
State::createFormatterFunc);
}

Expand All @@ -73,9 +82,13 @@ public static class State extends NpmFormatterStepStateBase implements Serializa
@Nullable private final TypedTsFmtConfigFile configFile;

public State(String stepName, Map<String, String> versions, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map<String, Object> inlineTsFmtSettings) throws IOException {
this(stepName, versions, Map.of(), projectDir, buildDir, cacheDir, npmPathResolver, configFile, inlineTsFmtSettings);
}

public State(String stepName, Map<String, String> versions, Map<String, String> dependencyOverrides, File projectDir, File buildDir, File cacheDir, NpmPathResolver npmPathResolver, @Nullable TypedTsFmtConfigFile configFile, @Nullable Map<String, Object> inlineTsFmtSettings) throws IOException {
super(stepName,
new NpmConfig(
replaceDevDependencies(NpmResourceHelper.readUtf8StringFromClasspath(TsFmtFormatterStep.class, "/com/diffplug/spotless/npm/tsfmt-package.json.template"), new TreeMap<>(versions)),
replaceDependencies(NpmResourceHelper.readUtf8StringFromClasspath(TsFmtFormatterStep.class, "/com/diffplug/spotless/npm/tsfmt-package.json.template"), new TreeMap<>(versions), dependencyOverrides),
NpmResourceHelper.readUtf8StringFromClasspath(PrettierFormatterStep.class,
"/com/diffplug/spotless/npm/common-serve.js",
"/com/diffplug/spotless/npm/tsfmt-serve.js"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ${devDependencies},
"express": "5.1.0",
"http-graceful-shutdown": "3.1.14"
},
"dependencies": {},
"dependencies": {},${dependencyOverrides}
"engines": {
"node": ">= 18"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ${devDependencies},
"express": "5.1.0",
"http-graceful-shutdown": "3.1.14"
},
"dependencies": {},
"dependencies": {},${dependencyOverrides}
"engines": {
"node": ">= 18"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ${devDependencies},
"express": "5.1.0",
"http-graceful-shutdown": "3.1.14"
},
"dependencies": {},
"dependencies": {},${dependencyOverrides}
"engines": {
"node": ">= 18"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,6 @@ static String prettierPackageJson(Map<String, String> dependencies) {
.reduce((a, b) -> a + ",\n " + b)
.orElse("");

return templateContent.replace("${devDependencies}", dependenciesList);
return templateContent.replace("${devDependencies}", dependenciesList).replace("${dependencyOverrides}", "");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright 2026 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.spotless.npm;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Map;
import java.util.TreeMap;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class NpmPackageJsonRenderingTest {

private static final String PRETTIER_TEMPLATE = "/com/diffplug/spotless/npm/prettier-package.json.template";

private static String template(String path) {
return NpmResourceHelper.readUtf8StringFromClasspath(NpmPackageJsonRenderingTest.class, path);
}

private static Map<String, String> sorted(Map<String, String> map) {
return new TreeMap<>(map);
}

@Nested
@DisplayName("given no dependency overrides")
class NoOverrides {

@Test
@DisplayName("when rendering the package.json then it is byte-identical to the output before overrides existed")
void rendersUnchangedPackageJson() {
// given
Map<String, String> devDependencies = sorted(Map.of("prettier", "3.0.0", "prettier-plugin-sql", "0.18.0"));

// when
String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), devDependencies, Map.of());

// then
assertThat(packageJson).isEqualTo("""
{
"name": "spotless-prettier",
"version": "4.0.1",
"description": "Spotless formatter step for running prettier as a rest service.",
"repository": "https://github.com/diffplug/spotless",
"license": "Apache-2.0",
"scripts": {
"start": "node serve.js"
},
"devDependencies": {
"prettier": "3.0.0",
"prettier-plugin-sql": "0.18.0",
"express": "5.1.0",
"http-graceful-shutdown": "3.1.14"
},
"dependencies": {},
"engines": {
"node": ">= 18"
}
}
""".replace("\t\t\t\t\t", ""));
}

@Test
@DisplayName("when using the legacy devDependencies-only method then the result equals rendering with empty overrides")
void legacyMethodIsEquivalent() {
Map<String, String> devDependencies = sorted(Map.of("prettier", "3.0.0"));

assertThat(NpmFormatterStepStateBase.replaceDevDependencies(template(PRETTIER_TEMPLATE), devDependencies))
.isEqualTo(NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), devDependencies, Map.of()));
}
}

@Nested
@DisplayName("given dependency overrides")
class WithOverrides {

@Test
@DisplayName("when rendering then an overrides block is written with entries sorted by name")
void rendersSortedOverridesBlock() {
// given
Map<String, String> devDependencies = sorted(Map.of("prettier", "3.0.0", "prettier-plugin-sql", "0.18.0"));
Map<String, String> overrides = Map.of("sql-formatter", "15.0.2", "nan", "2.22.0");

// when
String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE), devDependencies, overrides);

// then
assertThat(packageJson).contains("""
"dependencies": {},
"overrides": {
"nan": "2.22.0",
"sql-formatter": "15.0.2"
},
"engines": {
""".replace("\t\t\t\t\t", ""));
}

@Test
@DisplayName("when a value is an npm $reference then it is written verbatim")
void keepsDollarReferences() {
String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE),
sorted(Map.of("prettier", "3.0.0")), Map.of("prettier", "$prettier"));

assertThat(packageJson).contains("\t\t\"prettier\": \"$prettier\"\n");
}

@Test
@DisplayName("when a key or value contains JSON special characters then they are escaped")
void escapesJsonSpecialCharacters() {
String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(PRETTIER_TEMPLATE),
sorted(Map.of("prettier", "3.0.0")), Map.of("we\"ird", "1.0.0\\x"));

assertThat(packageJson).contains("\t\t\"we\\\"ird\": \"1.0.0\\\\x\"\n");
}

@ParameterizedTest(name = "{0}")
@ValueSource(strings = {
"/com/diffplug/spotless/npm/prettier-package.json.template",
"/com/diffplug/spotless/npm/eslint-package.json.template",
"/com/diffplug/spotless/npm/tsfmt-package.json.template"})
@DisplayName("when rendering any npm formatter template then the overrides block is present and no placeholder is left")
void supportedByAllNpmTemplates(String templatePath) {
String packageJson = NpmFormatterStepStateBase.replaceDependencies(template(templatePath),
sorted(Map.of("some-formatter", "1.0.0")), Map.of("transitive-lib", "2.0.0"));

assertThat(packageJson)
.contains("\t\"overrides\": {\n\t\t\"transitive-lib\": \"2.0.0\"\n\t},\n")
.doesNotContain("${");
}
}
}
2 changes: 2 additions & 0 deletions plugin-gradle/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`).

## [Unreleased]
### Added
- Add `dependencyOverrides(Map)` to `prettier()`, `eslint()` and `tsfmt()` to pin transitive npm dependencies via npm `overrides`. ([#2534](https://github.com/diffplug/spotless/issues/2534))
### Fixed
- GrEclipse no longer leaves a `file%3A...` directory in the working directory. ([#2019](https://github.com/diffplug/spotless/issues/2019))

Expand Down
Loading
Loading