diff --git a/src/main/java/edu/hm/hafner/util/CSharpNamespaceDetector.java b/src/main/java/edu/hm/hafner/util/CSharpNamespaceDetector.java new file mode 100644 index 00000000..6b2eb2a6 --- /dev/null +++ b/src/main/java/edu/hm/hafner/util/CSharpNamespaceDetector.java @@ -0,0 +1,27 @@ +package edu.hm.hafner.util; + +import java.util.regex.Pattern; + +/** + * Detects the namespace of a C# workspace file. + * + * @author Ullrich Hafner + */ +class CSharpNamespaceDetector extends PackageDetector { + private static final Pattern NAMESPACE_PATTERN = Pattern.compile("^\\s*namespace\\s+([^{]*)\\s*\\{?\\s*$"); + + CSharpNamespaceDetector(final FileSystemFacade fileSystem) { + super(fileSystem); + } + + @Override + public boolean accepts(final String fileName) { + return fileName.endsWith(".cs"); + } + + @Override + Pattern getPattern() { + return NAMESPACE_PATTERN; + } +} + diff --git a/src/main/java/edu/hm/hafner/util/JavaPackageDetector.java b/src/main/java/edu/hm/hafner/util/JavaPackageDetector.java new file mode 100644 index 00000000..66a09af2 --- /dev/null +++ b/src/main/java/edu/hm/hafner/util/JavaPackageDetector.java @@ -0,0 +1,27 @@ +package edu.hm.hafner.util; + +import java.util.regex.Pattern; + +/** + * Detects the package name of a Java file. + * + * @author Ullrich Hafner + */ +class JavaPackageDetector extends PackageDetector { + private static final Pattern PACKAGE_PATTERN = Pattern.compile( + "^\\s*package\\s*([a-z]+[.\\w]*)\\s*;.*"); + + JavaPackageDetector(final FileSystemFacade fileSystem) { + super(fileSystem); + } + + @Override + Pattern getPattern() { + return PACKAGE_PATTERN; + } + + @Override + public boolean accepts(final String fileName) { + return fileName.endsWith(".java"); + } +} diff --git a/src/main/java/edu/hm/hafner/util/KotlinPackageDetector.java b/src/main/java/edu/hm/hafner/util/KotlinPackageDetector.java new file mode 100644 index 00000000..45f4222c --- /dev/null +++ b/src/main/java/edu/hm/hafner/util/KotlinPackageDetector.java @@ -0,0 +1,28 @@ +package edu.hm.hafner.util; + +import java.util.regex.Pattern; + +/** + * Detects the package name of a Kotlin file. + * + * @author Bastian Kersting + */ +class KotlinPackageDetector extends PackageDetector { + private static final Pattern PACKAGE_PATTERN = Pattern.compile( + "^\\s*package\\s*([a-z]+[.\\w]*)\\s*.*"); + + @VisibleForTesting + KotlinPackageDetector(final FileSystemFacade fileSystem) { + super(fileSystem); + } + + @Override + Pattern getPattern() { + return PACKAGE_PATTERN; + } + + @Override + boolean accepts(final String fileName) { + return fileName.endsWith(".kt"); + } +} diff --git a/src/main/java/edu/hm/hafner/util/PackageDetector.java b/src/main/java/edu/hm/hafner/util/PackageDetector.java new file mode 100644 index 00000000..af0cbd0c --- /dev/null +++ b/src/main/java/edu/hm/hafner/util/PackageDetector.java @@ -0,0 +1,110 @@ +package edu.hm.hafner.util; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.apache.commons.io.input.BOMInputStream; + +import com.google.errorprone.annotations.MustBeClosed; + +/** + * Base class for package detectors. + * + * @author Ullrich Hafner + */ +abstract class PackageDetector { + private final FileSystemFacade fileSystem; + + /** + * Creates a new instance of {@link PackageDetector}. + * + * @param fileSystem + * file system facade + */ + PackageDetector(final FileSystemFacade fileSystem) { + this.fileSystem = fileSystem; + } + + /** + * Detects the package or namespace name of the specified file. + * + * @param fileName + * the file name of the file to scan + * @param charset + * the charset to use when reading the source files + * + * @return the detected package or namespace name + */ + public Optional detectPackageName(final String fileName, final Charset charset) { + try (var stream = fileSystem.openFile(fileName)) { + return detectPackageName(stream, charset); + } + catch (IOException | InvalidPathException ignore) { + // ignore IO errors + } + return Optional.empty(); + } + + private Optional detectPackageName(final InputStream stream, final Charset charset) throws IOException { + try (var buffer = new BufferedReader( + new InputStreamReader(BOMInputStream.builder().setInputStream(stream).get(), charset))) { + return detectPackageName(buffer.lines()); + } + } + + /** + * Detects the package or namespace name of the specified input stream. The stream will be closed automatically by + * the caller of this method. + * + * @param lines + * the content of the file to scan + * + * @return the detected package or namespace name + */ + private Optional detectPackageName(final Stream lines) { + Pattern pattern = getPattern(); + return lines.map(pattern::matcher) + .filter(Matcher::matches) + .findFirst() + .map(matcher -> matcher.group(1)) + .map(String::trim); + } + + /** + * Returns the Pattern for the Package Name in this kind of file. + * + * @return the Pattern. + */ + abstract Pattern getPattern(); + + /** + * Returns whether this classifier accepts the specified file for processing. + * + * @param fileName + * the file name + * + * @return {@code true} if the classifier accepts the specified file for processing. + */ + abstract boolean accepts(String fileName); + + /** + * Facade for file system operations. May be replaced by stubs in test cases. + */ + @VisibleForTesting + static class FileSystemFacade { + @MustBeClosed + InputStream openFile(final String fileName) throws IOException, InvalidPathException { + return Files.newInputStream(Paths.get(fileName)); + } + } +} diff --git a/src/main/java/edu/hm/hafner/util/PackageDetectorFactory.java b/src/main/java/edu/hm/hafner/util/PackageDetectorFactory.java new file mode 100644 index 00000000..d5499543 --- /dev/null +++ b/src/main/java/edu/hm/hafner/util/PackageDetectorFactory.java @@ -0,0 +1,31 @@ +package edu.hm.hafner.util; + +import edu.hm.hafner.util.PackageDetector.FileSystemFacade; + +/** + * Factory to create package detectors. + * + * @author Ullrich Hafner + */ +public final class PackageDetectorFactory { + /** + * Creates a new package detector runner that uses the detectors for Java, Kotlin, and C#. + * + * @return the package detector runner + */ + public static PackageDetectorRunner createPackageDetectors() { + return createPackageDetectors(new FileSystemFacade()); + } + + @VisibleForTesting + static PackageDetectorRunner createPackageDetectors(final FileSystemFacade facade) { + return new PackageDetectorRunner( + new JavaPackageDetector(facade), + new KotlinPackageDetector(facade), + new CSharpNamespaceDetector(facade)); + } + + private PackageDetectorFactory() { + // prevents instantiation + } +} diff --git a/src/main/java/edu/hm/hafner/util/PackageDetectorRunner.java b/src/main/java/edu/hm/hafner/util/PackageDetectorRunner.java new file mode 100644 index 00000000..bf3dc3d1 --- /dev/null +++ b/src/main/java/edu/hm/hafner/util/PackageDetectorRunner.java @@ -0,0 +1,37 @@ +package edu.hm.hafner.util; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Detects package or namespace names of files in the file system. + * + * @author Ullrich Hafner + */ +public class PackageDetectorRunner { + private final List detectors; + + PackageDetectorRunner(final PackageDetector... detectors) { + this.detectors = Arrays.asList(detectors); + } + + /** + * Detects the package name of the specified file based on several detector strategies. + * + * @param fileName + * the filename of the file to scan + * @param charset + * the charset to use when reading the source files + * + * @return the detected package name or {@link Optional#empty()} if no package name could be detected + */ + public Optional detectPackageName(final String fileName, final Charset charset) { + return detectors.stream() + .filter(detector -> detector.accepts(fileName)) + .map(detector -> detector.detectPackageName(fileName, charset)) + .flatMap(Optional::stream) + .findFirst(); + } +} diff --git a/src/test/java/edu/hm/hafner/util/PackageDetectorRunnerTest.java b/src/test/java/edu/hm/hafner/util/PackageDetectorRunnerTest.java new file mode 100644 index 00000000..a25dbca7 --- /dev/null +++ b/src/test/java/edu/hm/hafner/util/PackageDetectorRunnerTest.java @@ -0,0 +1,59 @@ +package edu.hm.hafner.util; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import edu.hm.hafner.util.PackageDetector.FileSystemFacade; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Tests the class {@link PackageDetectorRunner}. + * + * @author Ullrich Hafner + */ +class PackageDetectorRunnerTest extends ResourceTest { + @ParameterizedTest(name = "{index} => file={0}, expected package={1}") + @CsvSource({ + "MavenJavaTest.txt.java, hudson.plugins.tasks.util", + "ActionBinding.cs, Avaloq.SmartClient.Utilities", + "KotlinTest.txt.kt, edu.hm.kersting", + }) + void shouldExtractPackageNames(final String fileName, final String expectedPackage) throws IOException { + assertThat(detect(fileName)).contains(expectedPackage); + } + + @ParameterizedTest(name = "{index} => file={0}, no package found") + @ValueSource(strings = {"MavenJavaTest.txt", "empty.txt", "KotlinTest.txt"}) + void shouldNotAcceptFile(final String fileName) throws IOException { + assertThat(detect(fileName)).isEmpty(); + } + + private Optional detect(final String fileName) throws IOException { + try (InputStream stream = asInputStream(fileName)) { + var fileSystem = mock(FileSystemFacade.class); + when(fileSystem.openFile(fileName)).thenReturn(stream); + + var detectors = PackageDetectorFactory.createPackageDetectors(fileSystem); + + return detectors.detectPackageName(fileName, StandardCharsets.UTF_8); + } + } + + @Test + void shouldHandleException() throws IOException { + var fileSystem = mock(FileSystemFacade.class); + when(fileSystem.openFile(anyString())).thenThrow(new IOException("Simulated")); + + assertThat(PackageDetectorFactory.createPackageDetectors(fileSystem) + .detectPackageName("file.java", StandardCharsets.UTF_8)).isEmpty(); + } +} diff --git a/src/test/resources/edu/hm/hafner/util/ActionBinding.cs b/src/test/resources/edu/hm/hafner/util/ActionBinding.cs new file mode 100644 index 00000000..05878789 --- /dev/null +++ b/src/test/resources/edu/hm/hafner/util/ActionBinding.cs @@ -0,0 +1,18 @@ +/* + * Author: Hafner Ullrich + */ + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Windows.Forms; +using Avaloq.Utilities; +using log4net; + +namespace Avaloq.SmartClient.Utilities { + /// + /// Acts as mediator between an and a clickable Windows Forms control. + /// + public class ActionBinding : IDisposable + // Rest of the file is omitted +} diff --git a/src/test/resources/edu/hm/hafner/util/KotlinTest.txt b/src/test/resources/edu/hm/hafner/util/KotlinTest.txt new file mode 100644 index 00000000..7a3eded9 --- /dev/null +++ b/src/test/resources/edu/hm/hafner/util/KotlinTest.txt @@ -0,0 +1,5 @@ +package edu.hm.kersting + + class HelloWorld { + fun main() = println("Hello World") + } diff --git a/src/test/resources/edu/hm/hafner/util/KotlinTest.txt.kt b/src/test/resources/edu/hm/hafner/util/KotlinTest.txt.kt new file mode 100644 index 00000000..7a3eded9 --- /dev/null +++ b/src/test/resources/edu/hm/hafner/util/KotlinTest.txt.kt @@ -0,0 +1,5 @@ +package edu.hm.kersting + + class HelloWorld { + fun main() = println("Hello World") + } diff --git a/src/test/resources/edu/hm/hafner/util/MavenJavaTest.txt b/src/test/resources/edu/hm/hafner/util/MavenJavaTest.txt new file mode 100644 index 00000000..53e5af2f --- /dev/null +++ b/src/test/resources/edu/hm/hafner/util/MavenJavaTest.txt @@ -0,0 +1,6 @@ +package hudson.plugins.tasks.util; + +/** + * Indicates an orderly abortion of the processing. + * +// Rest of the file is omitted diff --git a/src/test/resources/edu/hm/hafner/util/MavenJavaTest.txt.java b/src/test/resources/edu/hm/hafner/util/MavenJavaTest.txt.java new file mode 100644 index 00000000..80ca1c98 --- /dev/null +++ b/src/test/resources/edu/hm/hafner/util/MavenJavaTest.txt.java @@ -0,0 +1,6 @@ +package hudson.plugins.tasks.util; + +/** + * Indicates an orderly abortion of the processing. + */ +// Rest of the file is omitted diff --git a/src/test/resources/edu/hm/hafner/util/relative.txt b/src/test/resources/edu/hm/hafner/util/empty.txt similarity index 100% rename from src/test/resources/edu/hm/hafner/util/relative.txt rename to src/test/resources/edu/hm/hafner/util/empty.txt