Skip to content

Commit dc6fae8

Browse files
timothyspargclaude
andcommitted
Add apply command
Introduces the apply subcommand which formats Java source files in place. FormattingService wraps the FileFormatter and takes a pre-scanned file list so the directory scan is not repeated. Includes integration tests covering default formatting, encoding, includes/excludes filtering, and springjavaformatconfig discovery. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e22e812 commit dc6fae8

File tree

14 files changed

+391
-0
lines changed

14 files changed

+391
-0
lines changed

spring-javaformat-cli/src/main/java/io/spring/javaformat/cli/SpringJavaFormatCommand.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@
2121
import picocli.CommandLine.Model.CommandSpec;
2222
import picocli.CommandLine.Spec;
2323

24+
import io.spring.javaformat.cli.format.ApplyCommand;
25+
2426
/**
2527
* Root picocli command.
2628
*
2729
* @author Tim Sparg
2830
*/
2931
@Component
3032
@Command(name = "spring-javaformat", mixinStandardHelpOptions = true, versionProvider = CliVersionProvider.class,
33+
subcommands = { ApplyCommand.class },
3134
description = "Formats and checks Java source files")
3235
@PicocliManaged
3336
class SpringJavaFormatCommand implements Runnable {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2017-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.javaformat.cli.format;
18+
19+
import java.io.File;
20+
import java.io.PrintWriter;
21+
import java.nio.file.Path;
22+
import java.nio.file.Paths;
23+
import java.util.concurrent.Callable;
24+
25+
import org.springframework.stereotype.Component;
26+
import picocli.CommandLine.Command;
27+
import picocli.CommandLine.Help.Ansi;
28+
import picocli.CommandLine.Mixin;
29+
import picocli.CommandLine.Model.CommandSpec;
30+
import picocli.CommandLine.Spec;
31+
32+
import io.spring.javaformat.cli.InputOptions;
33+
import io.spring.javaformat.formatter.FileEdit;
34+
import io.spring.javaformat.formatter.FileFormatterException;
35+
36+
/**
37+
* Apply formatting command.
38+
*
39+
* @author Tim Sparg
40+
*/
41+
@Component
42+
@Command(name = "apply", mixinStandardHelpOptions = true, description = "Apply formatting to the codebase")
43+
public class ApplyCommand implements Callable<Integer> {
44+
45+
private static final Path CWD = Paths.get("").toAbsolutePath();
46+
47+
@Spec
48+
private CommandSpec spec;
49+
50+
@Mixin
51+
private InputOptions options;
52+
53+
private final FormattingService formattingService;
54+
55+
@SuppressWarnings("NullAway.Init")
56+
ApplyCommand(FormattingService formattingService) {
57+
this.formattingService = formattingService;
58+
}
59+
60+
@Override
61+
public Integer call() {
62+
try {
63+
this.formattingService.format(this.options).filter(FileEdit::hasEdits).forEach(this::save);
64+
return 0;
65+
}
66+
catch (FileFormatterException ex) {
67+
err().println(ansi(
68+
"@|bold,red error:|@ unable to format file " + relativize(ex.getFile()) + ": " + ex.getMessage()));
69+
return 1;
70+
}
71+
}
72+
73+
private PrintWriter err() {
74+
return this.spec.commandLine().getErr();
75+
}
76+
77+
private PrintWriter out() {
78+
return this.spec.commandLine().getOut();
79+
}
80+
81+
private void save(FileEdit edit) {
82+
out().println(ansi("@|bold,green formatted|@ " + relativize(edit.getFile())));
83+
edit.save();
84+
}
85+
86+
private String ansi(String markup) {
87+
return Ansi.AUTO.string(markup);
88+
}
89+
90+
private static String relativize(File file) {
91+
try {
92+
return CWD.relativize(file.toPath().toAbsolutePath()).toString();
93+
}
94+
catch (IllegalArgumentException ex) {
95+
return file.getAbsolutePath();
96+
}
97+
}
98+
99+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2017-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.javaformat.cli.format;
18+
19+
import java.io.File;
20+
import java.util.List;
21+
import java.util.stream.Stream;
22+
23+
import org.springframework.stereotype.Component;
24+
25+
import io.spring.javaformat.cli.InputOptions;
26+
import io.spring.javaformat.cli.scan.FileFormatterFactory;
27+
import io.spring.javaformat.cli.scan.FileScanner;
28+
import io.spring.javaformat.formatter.FileEdit;
29+
import io.spring.javaformat.formatter.FileFormatter;
30+
import io.spring.javaformat.formatter.FileFormatterException;
31+
32+
/**
33+
* Applies formatting to scanned files.
34+
*
35+
* @author Tim Sparg
36+
*/
37+
@Component
38+
public class FormattingService {
39+
40+
private final FileFormatterFactory fileFormatterFactory;
41+
42+
private final FileScanner fileScanner;
43+
44+
FormattingService(FileFormatterFactory fileFormatterFactory, FileScanner fileScanner) {
45+
this.fileFormatterFactory = fileFormatterFactory;
46+
this.fileScanner = fileScanner;
47+
}
48+
49+
public Stream<FileEdit> format(InputOptions options) throws FileFormatterException {
50+
return format(this.fileScanner.scan(options), options);
51+
}
52+
53+
public Stream<FileEdit> format(List<File> files, InputOptions options) throws FileFormatterException {
54+
FileFormatter formatter = this.fileFormatterFactory.create(options.path);
55+
return formatter.formatFiles(files, options.encoding, options.resolveLineSeparator());
56+
}
57+
58+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2017-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Apply formatting command.
19+
*
20+
* @author Tim Sparg
21+
*/
22+
@NullMarked
23+
package io.spring.javaformat.cli.format;
24+
25+
import org.jspecify.annotations.NullMarked;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2017-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.javaformat.cli;
18+
19+
import java.io.IOException;
20+
import java.io.PrintWriter;
21+
import java.io.StringWriter;
22+
import java.io.UncheckedIOException;
23+
import java.nio.file.Files;
24+
import java.nio.file.Path;
25+
import java.util.concurrent.Callable;
26+
import java.util.stream.Stream;
27+
28+
import org.springframework.beans.factory.annotation.Autowired;
29+
import org.springframework.boot.test.context.SpringBootTest;
30+
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
31+
import picocli.CommandLine;
32+
import picocli.CommandLine.IFactory;
33+
34+
/**
35+
* Base class for command integration tests.
36+
*
37+
* @author Tim Sparg
38+
*/
39+
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
40+
abstract class AbstractCommandIntegrationTests {
41+
42+
@Autowired
43+
IFactory factory;
44+
45+
abstract Callable<Integer> command();
46+
47+
int execute(StringWriter out, StringWriter err, String... args) {
48+
return new CommandLine(command(), this.factory).setOut(new PrintWriter(out))
49+
.setErr(new PrintWriter(err))
50+
.execute(args);
51+
}
52+
53+
void copyFixture(Path fixturesDir, String name, Path target) throws IOException {
54+
Path fixtureDir = fixturesDir.resolve(name);
55+
try (Stream<Path> files = Files.walk(fixtureDir)) {
56+
files.filter(Files::isRegularFile).forEach((source) -> {
57+
try {
58+
Path dest = target.resolve(fixtureDir.relativize(source));
59+
Files.createDirectories(dest.getParent());
60+
Files.copy(source, dest);
61+
}
62+
catch (IOException ex) {
63+
throw new UncheckedIOException(ex);
64+
}
65+
});
66+
}
67+
}
68+
69+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2017-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.javaformat.cli;
18+
19+
import java.io.StringWriter;
20+
import java.nio.charset.Charset;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.io.TempDir;
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
28+
import io.spring.javaformat.cli.format.ApplyCommand;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
32+
/**
33+
* Integration tests for {@link ApplyCommand}.
34+
*
35+
* @author Tim Sparg
36+
*/
37+
class ApplyCommandIntegrationTests extends AbstractCommandIntegrationTests {
38+
39+
private static final Path FIXTURES_DIR = Path.of("src/test/resources/fixtures/format");
40+
41+
@Autowired
42+
ApplyCommand applyCommand;
43+
44+
@Override
45+
ApplyCommand command() {
46+
return this.applyCommand;
47+
}
48+
49+
@Test
50+
void defaultEncodingFormatsFile(@TempDir Path tempDir) throws Exception {
51+
copyFixture(FIXTURES_DIR, "default", tempDir);
52+
String before = Files.readString(tempDir.resolve("Main.java"));
53+
54+
execute(new StringWriter(), new StringWriter(), tempDir.toString());
55+
56+
assertThat(Files.readString(tempDir.resolve("Main.java"))).isNotEqualTo(before);
57+
}
58+
59+
@Test
60+
void nonDefaultEncodingFormatsFile(@TempDir Path tempDir) throws Exception {
61+
Charset latin1 = Charset.forName("ISO-8859-1");
62+
copyFixture(FIXTURES_DIR, "latin1", tempDir);
63+
String before = Files.readString(tempDir.resolve("Main.java"), latin1);
64+
65+
execute(new StringWriter(), new StringWriter(), "--encoding", "ISO-8859-1", tempDir.toString());
66+
67+
String after = Files.readString(tempDir.resolve("Main.java"), latin1);
68+
assertThat(after).isNotEqualTo(before);
69+
assertThat(after).contains("é");
70+
}
71+
72+
@Test
73+
void includesOnlyFormatsMatchingFiles(@TempDir Path tempDir) throws Exception {
74+
copyFixture(FIXTURES_DIR, "default", tempDir);
75+
String mainBefore = readFile(tempDir, "Main.java");
76+
String noteBefore = Files.readString(tempDir.resolve("notes.txt"));
77+
String excludedBefore = readFile(tempDir, "excluded/Excluded.java");
78+
79+
execute(new StringWriter(), new StringWriter(), "--includes", "**/Main.java", tempDir.toString());
80+
81+
assertThat(readFile(tempDir, "Main.java")).isNotEqualTo(mainBefore);
82+
assertThat(Files.readString(tempDir.resolve("notes.txt"))).isEqualTo(noteBefore);
83+
assertThat(readFile(tempDir, "excluded/Excluded.java")).isEqualTo(excludedBefore);
84+
}
85+
86+
@Test
87+
void excludesSkipsExcludedFiles(@TempDir Path tempDir) throws Exception {
88+
copyFixture(FIXTURES_DIR, "default", tempDir);
89+
String mainBefore = readFile(tempDir, "Main.java");
90+
String excludedBefore = readFile(tempDir, "excluded/Excluded.java");
91+
92+
execute(new StringWriter(), new StringWriter(), "--excludes", "excluded/**", tempDir.toString());
93+
94+
assertThat(readFile(tempDir, "Main.java")).isNotEqualTo(mainBefore);
95+
assertThat(readFile(tempDir, "excluded/Excluded.java")).isEqualTo(excludedBefore);
96+
}
97+
98+
@Test
99+
void springJavaFormatConfigChangesIndentationStyle(@TempDir Path tempDir) throws Exception {
100+
copyFixture(FIXTURES_DIR, "spaces-config", tempDir);
101+
102+
execute(new StringWriter(), new StringWriter(), tempDir.toString());
103+
104+
String formatted = readFile(tempDir, "Main.java");
105+
assertThat(formatted).contains(" void method() {");
106+
assertThat(formatted).doesNotContain("\tvoid method() {");
107+
}
108+
109+
private String readFile(Path dir, String path) throws Exception {
110+
return Files.readString(dir.resolve(path));
111+
}
112+
113+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class Unformatted {
2+
void method() {
3+
}
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class Unformatted {
2+
void method() {
3+
}
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
some notes

0 commit comments

Comments
 (0)