From d71c2e5d07f0e1a1ca0124fbbe8d06cfaf8780ac Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Sun, 29 Mar 2026 22:29:43 +0900 Subject: [PATCH 1/2] jextract: add filter-include/exclude same as wrap-java has This breaks wrap-java properties in swift-java.config which are now javaFilterInclude/Exlcude! We will change the settings format once again... a full redesign is in place here. --- .../Sources/JavaCommonsCSV/swift-java.config | 2 +- .../Convenience/String+Extensions.swift | 9 + .../FFM/FFMSwift2JavaGenerator.swift | 11 + .../JExtractSwiftLib/JExtractFileFilter.swift | 275 ++++++++++++++++ .../JNI/JNISwift2JavaGenerator.swift | 11 + Sources/JExtractSwiftLib/Swift2Java.swift | 33 ++ .../Swift2JavaTranslator.swift | 11 + .../Configuration.swift | 20 +- .../Commands/ConfigureCommand.swift | 20 +- .../Commands/JExtractCommand.swift | 23 ++ .../Commands/WrapJavaCommand.swift | 24 +- Sources/SwiftJavaTool/CommonOptions.swift | 10 - Sources/SwiftJavaTool/ExcludedJDKTypes.swift | 8 +- .../JavaClassTranslator.swift | 2 +- .../JExtractFileFilterTests.swift | 296 ++++++++++++++++++ 15 files changed, 723 insertions(+), 32 deletions(-) create mode 100644 Sources/JExtractSwiftLib/JExtractFileFilter.swift create mode 100644 Tests/JExtractSwiftTests/JExtractFileFilterTests.swift diff --git a/Samples/JavaDependencySampleApp/Sources/JavaCommonsCSV/swift-java.config b/Samples/JavaDependencySampleApp/Sources/JavaCommonsCSV/swift-java.config index dc38efd9..8f46ce2c 100644 --- a/Samples/JavaDependencySampleApp/Sources/JavaCommonsCSV/swift-java.config +++ b/Samples/JavaDependencySampleApp/Sources/JavaCommonsCSV/swift-java.config @@ -5,7 +5,7 @@ "org.apache.commons.csv.CSVParser" : "CSVParser", "org.apache.commons.csv.CSVRecord" : "CSVRecord" }, - "filterExclude" : [ + "javaFilterExclude" : [ "org.apache.commons.csv.CSVFormat$Predefined", ], "dependencies" : [ diff --git a/Sources/JExtractSwiftLib/Convenience/String+Extensions.swift b/Sources/JExtractSwiftLib/Convenience/String+Extensions.swift index 37494fb3..f7439e79 100644 --- a/Sources/JExtractSwiftLib/Convenience/String+Extensions.swift +++ b/Sources/JExtractSwiftLib/Convenience/String+Extensions.swift @@ -66,6 +66,15 @@ extension String { .joined() } + /// If the string ends with `.swift`, return it without that suffix; + /// otherwise return self unchanged + func dropSwiftFileSuffix() -> String { + if hasSuffix(".swift") { + return String(dropLast(".swift".count)) + } + return self + } + /// Looks up self as a SwiftJava wrapped class name and converts it /// into a `JavaType.class` if it exists in `lookupTable`. func parseJavaClassFromSwiftJavaName(in lookupTable: [String: String]) -> JavaType? { diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator.swift index dd14ee8e..f9554bee 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator.swift @@ -79,6 +79,17 @@ package class FFMSwift2JavaGenerator: Swift2JavaGenerator { return String(fileName.replacing(".swift", with: "+SwiftJava.swift")) } ) + // Also include filtered-out files so SwiftPM gets the empty outputs it expects + for path in translator.filteredOutPaths { + guard let fileName = path.split(separator: PATH_SEPARATOR).last else { + continue + } + if fileName.hasSuffix(".swift") { + self.expectedOutputSwiftFileNames.insert( + String(fileName.replacing(".swift", with: "+SwiftJava.swift")) + ) + } + } self.expectedOutputSwiftFileNames.insert("\(translator.swiftModuleName)Module+SwiftJava.swift") self.expectedOutputSwiftFileNames.insert("Foundation+SwiftJava.swift") } else { diff --git a/Sources/JExtractSwiftLib/JExtractFileFilter.swift b/Sources/JExtractSwiftLib/JExtractFileFilter.swift new file mode 100644 index 00000000..42a1b035 --- /dev/null +++ b/Sources/JExtractSwiftLib/JExtractFileFilter.swift @@ -0,0 +1,275 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftJavaConfigurationShared + +// ==== ----------------------------------------------------------------------- +// MARK: Swift filter pattern classification + +/// A filter pattern is either a file-path pattern (uses `/`) or a type-name +/// pattern (uses `.`). Plain names with neither separator match both +enum SwiftFilterPatternKind { + /// Pattern contains `/` or `**` — matches against relative file paths + case filePath + /// Pattern contains `.` — matches against qualified type names + case typeName + /// Plain name with no separators — matches against both + case plain +} + +func classifyPattern(_ pattern: String) -> SwiftFilterPatternKind { + if pattern.contains("/") || pattern.contains("**") { + return .filePath + } + if pattern.contains(".") { + return .typeName + } + return .plain +} + +// ==== ----------------------------------------------------------------------- +// MARK: Glob-like matching + +/// Match a value split by `separator` against a glob-like pattern split by the +/// same separator. Supports `**` (zero or more segments) and trailing `*` +/// within a segment +private func matchesGlob( + value: String, + pattern: String, + separator: Character +) -> Bool { + let valueParts = value.split(separator: separator, omittingEmptySubsequences: true) + let patternParts = pattern.split(separator: separator, omittingEmptySubsequences: true) + + return matchParts( + valueParts: Array(valueParts), + valueIdx: 0, + patternParts: Array(patternParts), + patternIdx: 0 + ) +} + +/// Walk `valueParts` and `patternParts` in lockstep starting from the given +/// positions. Literal segments must match one-to-one; a `**` segment can +/// consume zero or more value segments (resolved by trying every possible +/// skip length recursively) +private func matchParts( + valueParts: [Substring], + valueIdx: Int, + patternParts: [Substring], + patternIdx: Int +) -> Bool { + var valueIdx = valueIdx + var patternIdx = patternIdx + + while patternIdx < patternParts.count { + let currentPattern = patternParts[patternIdx] + + if currentPattern == "**" { + return matchDoubleStarWildcard( + valueParts: valueParts, + valueIdx: valueIdx, + patternParts: patternParts, + doubleStarIdx: patternIdx + ) + } + + // Pattern still has literal parts but value is exhausted — no match + guard valueIdx < valueParts.count else { + return false + } + + guard matchSegment(String(valueParts[valueIdx]), against: String(currentPattern)) else { + return false + } + valueIdx += 1 + patternIdx += 1 + } + + // Full match only when both sides are exhausted + return valueIdx == valueParts.count +} + +/// Handle a `**` wildcard at `doubleStarPos` in the pattern. +/// `**` matches zero or more consecutive value segments, so we try every +/// possible number of skipped segments and recurse on the remainder +private func matchDoubleStarWildcard( + valueParts: [Substring], + valueIdx: Int, + patternParts: [Substring], + doubleStarIdx: Int +) -> Bool { + // `**` at the end of the pattern matches everything remaining + if doubleStarIdx == patternParts.count - 1 { + return true + } + + // Try consuming 0, 1, 2, ... value segments with the `**` + for skipCount in valueIdx...valueParts.count { + if matchParts( + valueParts: valueParts, + valueIdx: skipCount, + patternParts: patternParts, + patternIdx: doubleStarIdx + 1 + ) { + return true + } + } + return false +} + +/// Match a single segment against a pattern segment. +/// Supports trailing `*` wildcard (e.g. `Us*` matches `User`) +private func matchSegment(_ segment: String, against pattern: String) -> Bool { + if pattern == "*" { + return true + } + if pattern.hasSuffix("*") { + let prefix = String(pattern.dropLast()) + return segment.hasPrefix(prefix) + } + return segment == pattern +} + +// ==== ----------------------------------------------------------------------- +// MARK: File-path matching + +/// Check whether `relativePath` (including `.swift` extension, using `/` separators) +/// matches the given glob-like `pattern`. +/// +/// Supported pattern syntax: +/// - `**` matches zero or more path segments +/// - `*` at the end of a segment matches any suffix (e.g. `Us*` matches `User.swift`) +/// - exact segment match otherwise +func matchesFilePathFilter(relativePath: String, pattern: String) -> Bool { + matchesGlob(value: relativePath, pattern: pattern, separator: "/") +} + +// ==== ----------------------------------------------------------------------- +// MARK: Type-name matching + +/// Check whether a qualified type name (e.g. `Something.Other`) matches a +/// dot-separated pattern. +/// +/// Supported pattern syntax: +/// - `**` matches zero or more name components +/// - `*` at the end of a component matches any suffix +/// - exact component match otherwise +func matchesTypeNameFilter(qualifiedName: String, pattern: String) -> Bool { + matchesGlob(value: qualifiedName, pattern: pattern, separator: ".") +} + +// ==== ----------------------------------------------------------------------- +// MARK: Combined filter application + +/// Determine whether a file at the given `relativePath` (including `.swift` +/// extension) should be included in jextract processing, based on the +/// include/exclude filters in `config`. +/// +/// Only file-path patterns (containing `/`) and plain patterns (no `/` or `.`) +/// are checked here. Type-name patterns are skipped — use `shouldJExtractType` +/// for those +func shouldJExtractFile(relativePath: String, config: Configuration) -> Bool { + if let includeFilters = config.swiftFilterInclude, !includeFilters.isEmpty { + // Must match at least one file-level include pattern. + // If all include patterns are type-name patterns, don't filter at file level + let filePatterns = includeFilters.filter { classifyPattern($0) != .typeName } + if !filePatterns.isEmpty { + let included = filePatterns.contains { pattern in + matchesFilePattern(relativePath: relativePath, pattern: pattern) + } + guard included else { + return false + } + } + } + + if let excludeFilters = config.swiftFilterExclude, !excludeFilters.isEmpty { + let filePatterns = excludeFilters.filter { classifyPattern($0) != .typeName } + let excluded = filePatterns.contains { pattern in + matchesFilePattern(relativePath: relativePath, pattern: pattern) + } + if excluded { + return false + } + } + + return true +} + +/// Match a file pattern against a relative path. Plain patterns (no `/` or `.`) +/// are matched against the filename without the `.swift` extension; file-path +/// patterns are matched against the full relative path as-is +private func matchesFilePattern(relativePath: String, pattern: String) -> Bool { + switch classifyPattern(pattern) { + case .plain: + // Plain pattern like "MyType" — match against just the filename sans .swift + let fileName = relativePath.split(separator: "/").last.map(String.init) ?? relativePath + return matchSegment(fileName.dropSwiftFileSuffix(), against: pattern) + case .filePath: + return matchesFilePathFilter(relativePath: relativePath, pattern: pattern) + case .typeName: + return false + } +} + +/// Determine whether a type with the given `qualifiedName` (e.g. `MyClass` or +/// `Outer.Inner`) should be extracted, based on the include/exclude filters in +/// `config`. +/// +/// Only type-name patterns (containing `.`) and plain patterns (no `/` or `.`) +/// are checked here. File-path patterns are skipped — use `shouldJExtractFile` +/// for those +func shouldJExtractType(qualifiedName: String, config: Configuration) -> Bool { + if let includeFilters = config.swiftFilterInclude, !includeFilters.isEmpty { + let typePatterns = includeFilters.filter { classifyPattern($0) != .filePath } + if !typePatterns.isEmpty { + let included = typePatterns.contains { pattern in + let kind = classifyPattern(pattern) + switch kind { + case .typeName: + return matchesTypeNameFilter(qualifiedName: qualifiedName, pattern: pattern) + case .plain: + // Plain pattern: match against the top-level name + return matchSegment(qualifiedName.split(separator: ".").first.map(String.init) ?? qualifiedName, against: pattern) + case .filePath: + return false + } + } + guard included else { + return false + } + } + } + + if let excludeFilters = config.swiftFilterExclude, !excludeFilters.isEmpty { + let typePatterns = excludeFilters.filter { classifyPattern($0) != .filePath } + let excluded = typePatterns.contains { pattern in + let kind = classifyPattern(pattern) + switch kind { + case .typeName: + return matchesTypeNameFilter(qualifiedName: qualifiedName, pattern: pattern) + case .plain: + return matchSegment(qualifiedName.split(separator: ".").first.map(String.init) ?? qualifiedName, against: pattern) + case .filePath: + return false + } + } + if excluded { + return false + } + } + + return true +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index 27ca90e0..5a3a9407 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -91,6 +91,17 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { return String(fileName.replacing(".swift", with: "+SwiftJava.swift")) } ) + // Also include filtered-out files so SwiftPM gets the empty outputs it expects + for path in translator.filteredOutPaths { + guard let fileName = path.split(separator: PATH_SEPARATOR).last else { + continue + } + if fileName.hasSuffix(".swift") { + self.expectedOutputSwiftFileNames.insert( + String(fileName.replacing(".swift", with: "+SwiftJava.swift")) + ) + } + } self.expectedOutputSwiftFileNames.insert("\(translator.swiftModuleName)Module+SwiftJava.swift") self.expectedOutputSwiftFileNames.insert("Foundation+SwiftJava.swift") } else { diff --git a/Sources/JExtractSwiftLib/Swift2Java.swift b/Sources/JExtractSwiftLib/Swift2Java.swift index 4a75f218..a7920958 100644 --- a/Sources/JExtractSwiftLib/Swift2Java.swift +++ b/Sources/JExtractSwiftLib/Swift2Java.swift @@ -53,12 +53,27 @@ public struct SwiftToJava { let allFiles = collectAllFiles(suffix: ".swift", in: inputPaths, log: translator.log) + let hasFilters = + !(config.swiftFilterInclude ?? []).isEmpty || + !(config.swiftFilterExclude ?? []).isEmpty + // Register files to the translator. let fileManager = FileManager.default for file in allFiles { guard canExtract(from: file) else { continue } + + // Apply jextract include/exclude filters if configured + if hasFilters { + let relativePath = computeRelativePath(file: file, inputPaths: inputPaths) + guard shouldJExtractFile(relativePath: relativePath, config: config) else { + log.info("Skipping file (filtered out): \(file.path)") + translator.filteredOutPaths.append(file.path) + continue + } + } + guard let data = fileManager.contents(atPath: file.path) else { continue } @@ -124,6 +139,24 @@ public struct SwiftToJava { return true } + /// Compute a relative path (sans `.swift` extension) for a file against the + /// input paths, suitable for jextract filter matching + func computeRelativePath(file: URL, inputPaths: [URL]) -> String { + let filePath = file.standardizedFileURL.path + + for inputPath in inputPaths { + let basePath = inputPath.standardizedFileURL.path + let baseWithSlash = basePath.hasSuffix("/") ? basePath : basePath + "/" + if filePath.hasPrefix(baseWithSlash) { + let relative = String(filePath.dropFirst(baseWithSlash.count)) + return relative + } + } + + // Fallback: just the filename + return file.lastPathComponent + } + } extension URL { diff --git a/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift b/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift index 18cf5991..c24794e8 100644 --- a/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift +++ b/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift @@ -34,6 +34,11 @@ public final class Swift2JavaTranslator { var inputs: [SwiftJavaInputFile] = [] + /// File paths that were skipped by swift filters but still need empty output + /// files written (when --write-empty-files is set) so SwiftPM doesn't + /// complain about missing declared outputs + var filteredOutPaths: [String] = [] + /// A list of used Swift class names that live in dependencies, e.g. `JavaInteger` package var dependenciesClasses: [String] = [] @@ -264,6 +269,12 @@ extension Swift2JavaTranslator { return alreadyImported } + // Apply type-name filters (patterns with `.`) + guard shouldJExtractType(qualifiedName: fullName, config: config) else { + log.info("Skipping type (filtered out): \(fullName)") + return nil + } + let importedNominal = try? ImportedNominalType(swiftNominal: nominal, lookupContext: lookupContext) importedTypes[fullName] = importedNominal diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index e3ab0aea..b11d4fdd 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -75,6 +75,18 @@ public struct Configuration: Codable { /// library. public var linkerExportListOutput: String? + /// Include only Swift source files or types matching these patterns during jextract. + /// File-path patterns (containing `/`): matched against relative file paths + /// (including `.swift` extension). Supports `*` and `**` wildcards. + /// Type-name patterns (containing `.`): matched against qualified type names + /// (e.g. `Something.Other` for nested types). + /// Plain names match both + public var swiftFilterInclude: [String]? + + /// Exclude Swift source files or types matching these patterns during jextract. + /// Same pattern syntax as swiftFilterInclude + public var swiftFilterExclude: [String]? + // ==== wrap-java --------------------------------------------------------- /// The Java class path that should be passed along to the swift-java tool. @@ -95,11 +107,11 @@ public struct Configuration: Codable { // Generate class files suitable for the specified Java SE release. public var targetCompatibility: JavaVersion? - /// Filter input Java types by their package prefix if set. - public var filterInclude: [String]? + /// Filter input Java types by their package prefix if set + public var javaFilterInclude: [String]? - /// Exclude input Java types by their package prefix or exact match. - public var filterExclude: [String]? + /// Exclude input Java types by their package prefix or exact match + public var javaFilterExclude: [String]? public var singleSwiftFileOutput: String? diff --git a/Sources/SwiftJavaTool/Commands/ConfigureCommand.swift b/Sources/SwiftJavaTool/Commands/ConfigureCommand.swift index b8946723..705dab66 100644 --- a/Sources/SwiftJavaTool/Commands/ConfigureCommand.swift +++ b/Sources/SwiftJavaTool/Commands/ConfigureCommand.swift @@ -66,6 +66,16 @@ extension SwiftJava { @Option(help: "A prefix that will be added to the names of the Swift types") var swiftTypePrefix: String? + + @Option(name: .long, help: "While scanning a classpath, inspect ONLY types included in these packages") + var filterInclude: [String] = [] + + @Option( + name: .long, + help: + "While scanning a classpath, skip types which match the filter prefix. You can exclude specific methods by using the `com.example.MyClass#method` format." + ) + var filterExclude: [String] = [] } } @@ -114,11 +124,11 @@ extension SwiftJava.ConfigureCommand { log.info("Run: emit configuration...") var (amendExistingConfig, config) = try getBaseConfigurationForWrite() - if !self.commonOptions.filterInclude.isEmpty { - log.debug("Generate Java->Swift type mappings. Active include filters: \(self.commonOptions.filterInclude)") - } else if let filters = config.filterInclude, !filters.isEmpty { + if !self.filterInclude.isEmpty { + log.debug("Generate Java->Swift type mappings. Active include filters: \(self.filterInclude)") + } else if let filters = config.javaFilterInclude, !filters.isEmpty { // take the package filter from the configuration file - self.commonOptions.filterInclude = filters + self.filterInclude = filters } else { log.debug("Generate Java->Swift type mappings. No package include filter applied.") } @@ -226,7 +236,7 @@ extension SwiftJava.ConfigureCommand { .dropLast(".class".count) ) - guard SwiftJava.shouldImport(javaCanonicalName: javaCanonicalName, commonOptions: self.commonOptions) else { + guard SwiftJava.shouldImport(javaCanonicalName: javaCanonicalName, filterInclude: self.filterInclude, filterExclude: self.filterExclude) else { log.info("Skip importing class: \(javaCanonicalName) due to include/exclude filters") return } diff --git a/Sources/SwiftJavaTool/Commands/JExtractCommand.swift b/Sources/SwiftJavaTool/Commands/JExtractCommand.swift index f03af349..dc7d1bab 100644 --- a/Sources/SwiftJavaTool/Commands/JExtractCommand.swift +++ b/Sources/SwiftJavaTool/Commands/JExtractCommand.swift @@ -109,6 +109,27 @@ extension SwiftJava { """ ) var linkerExportListOutput: String? + + @Option( + name: .long, + help: """ + Include only Swift source files matching these patterns during jextract. \ + Patterns are matched against relative file paths (without .swift extension). \ + Supports * (single-segment wildcard) and ** (recursive wildcard). \ + Example: --filter-include 'Models/**' + """ + ) + var filterInclude: [String] = [] + + @Option( + name: .long, + help: """ + Exclude Swift source files matching these patterns during jextract. \ + Same pattern syntax as --filter-include. \ + Example: --filter-exclude 'Internal/*' + """ + ) + var filterExclude: [String] = [] } } @@ -128,6 +149,8 @@ extension SwiftJava.JExtractCommand { configure(&config.asyncFuncMode, overrideWith: self.asyncFuncMode) configure(&config.generatedJavaSourcesListFileOutput, overrideWith: self.generatedJavaSourcesListFileOutput) configure(&config.linkerExportListOutput, overrideWith: self.linkerExportListOutput) + configure(&config.swiftFilterInclude, append: self.filterInclude) + configure(&config.swiftFilterExclude, append: self.filterExclude) try checkModeCompatibility(config: config) diff --git a/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift b/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift index 8d544d7f..63c2fcb0 100644 --- a/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift +++ b/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift @@ -62,6 +62,16 @@ extension SwiftJava { @Option(help: "If specified, a single Swift file will be generated containing all the generated code") var singleSwiftFileOutput: String? + @Option(name: .long, help: "While scanning a classpath, inspect ONLY types included in these packages") + var filterInclude: [String] = [] + + @Option( + name: .long, + help: + "While scanning a classpath, skip types which match the filter prefix. You can exclude specific methods by using the `com.example.MyClass#method` format." + ) + var filterExclude: [String] = [] + @Option(name: .customLong("android-api-version-file"), help: "Path to Android api-versions.xml for generating @available attributes based on API level data") var androidAPIVersionFile: String? } @@ -70,9 +80,9 @@ extension SwiftJava { extension SwiftJava.WrapJavaCommand { mutating func runSwiftJavaCommand(config: inout Configuration) async throws { - print("self.commonOptions.filterInclude = \(self.commonOptions.filterInclude)") - configure(&config.filterInclude, append: self.commonOptions.filterInclude) - configure(&config.filterExclude, append: self.commonOptions.filterExclude) + print("self.filterInclude = \(self.filterInclude)") + configure(&config.javaFilterInclude, append: self.filterInclude) + configure(&config.javaFilterExclude, append: self.filterExclude) configure(&config.singleSwiftFileOutput, overrideWith: self.singleSwiftFileOutput) // Get base classpath configuration for this target and configuration @@ -137,8 +147,8 @@ extension SwiftJava.WrapJavaCommand { translateAsClass: true ) - log.info("Active include filters: \(config.filterInclude ?? [])") - log.info("Active exclude filters: \(config.filterExclude ?? [])") + log.info("Active include filters: \(config.javaFilterInclude ?? [])") + log.info("Active exclude filters: \(config.javaFilterExclude ?? [])") // Keep track of all of the Java classes that will have // Swift-native implementations. @@ -355,7 +365,7 @@ extension SwiftJava.WrapJavaCommand { private func shouldImportJavaClass(_ javaClassName: String, config: Configuration) -> Bool { // If we have an inclusive filter, import only types from it - if let includes = config.filterInclude, !includes.isEmpty { + if let includes = config.javaFilterInclude, !includes.isEmpty { let anyIncludeFilterMatched = includes.contains { include in if javaClassName.starts(with: include) { // TODO: lower to trace level @@ -372,7 +382,7 @@ extension SwiftJava.WrapJavaCommand { } } // If we have an exclude filter, check for it as well - for exclude in config.filterExclude ?? [] { + for exclude in config.javaFilterExclude ?? [] { if javaClassName.starts(with: exclude) { log.info("Skip Java type: \(javaClassName) (does match exclude filter: \(exclude))") return false diff --git a/Sources/SwiftJavaTool/CommonOptions.swift b/Sources/SwiftJavaTool/CommonOptions.swift index 18faef4c..8f5e7ccc 100644 --- a/Sources/SwiftJavaTool/CommonOptions.swift +++ b/Sources/SwiftJavaTool/CommonOptions.swift @@ -65,16 +65,6 @@ extension SwiftJava { @Option(name: .shortAndLong, help: "Configure the level of logs that should be printed") var logLevel: JExtractSwiftLib.Logger.Level = .info - @Option(name: .long, help: "While scanning a classpath, inspect ONLY types included in these packages") - var filterInclude: [String] = [] - - @Option( - name: .long, - help: - "While scanning a classpath, skip types which match the filter prefix. You can exclude specific methods by using the `com.example.MyClass#method` format." - ) - var filterExclude: [String] = [] - @Option(help: "A path to a custom swift-java.config to use") var config: String? = nil } diff --git a/Sources/SwiftJavaTool/ExcludedJDKTypes.swift b/Sources/SwiftJavaTool/ExcludedJDKTypes.swift index ce757346..74f625b0 100644 --- a/Sources/SwiftJavaTool/ExcludedJDKTypes.swift +++ b/Sources/SwiftJavaTool/ExcludedJDKTypes.swift @@ -19,19 +19,19 @@ extension SwiftJava { "java.lang.Enum$EnumDesc", ] - static func shouldImport(javaCanonicalName: String, commonOptions: SwiftJava.CommonOptions) -> Bool { + static func shouldImport(javaCanonicalName: String, filterInclude: [String], filterExclude: [String]) -> Bool { if SwiftJava.ExcludedJDKTypes.contains(javaCanonicalName) { return false } - if !commonOptions.filterInclude.isEmpty { - let anyIncludeMatches = commonOptions.filterInclude.contains(where: { javaCanonicalName.hasPrefix($0) }) + if !filterInclude.isEmpty { + let anyIncludeMatches = filterInclude.contains(where: { javaCanonicalName.hasPrefix($0) }) guard anyIncludeMatches else { return false } } - for exclude in commonOptions.filterExclude { + for exclude in filterExclude { if javaCanonicalName.hasPrefix(exclude) { return false } diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index 508e97ca..4742cd8b 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -290,7 +290,7 @@ extension JavaClassTranslator { /// Only look at public and protected methods here. private func shouldExtract(method: Method, config: Configuration) -> Bool { // Check exclude filters, if they're applicable to methods: - for exclude in config.filterExclude ?? [] where exclude.contains("#") { + for exclude in config.javaFilterExclude ?? [] where exclude.contains("#") { let split = exclude.split(separator: "#") guard split.count == 2 else { self.log.warning("Malformed method exclude filter, must have only one '#' marker: \(exclude)") diff --git a/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift b/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift new file mode 100644 index 00000000..c8e77245 --- /dev/null +++ b/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift @@ -0,0 +1,296 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +// ==== ----------------------------------------------------------------------- +// MARK: File-path matching tests + +struct JExtractFileFilterTests { + + @Test("File path: exact match") + func filePathExactMatch() { + #expect(matchesFilePathFilter(relativePath: "MyType.swift", pattern: "MyType.swift")) + #expect(!matchesFilePathFilter(relativePath: "MyType.swift", pattern: "OtherType.swift")) + } + + @Test("File path: exact match with directory") + func filePathExactMatchWithDirectory() { + #expect(matchesFilePathFilter(relativePath: "Models/User.swift", pattern: "Models/User.swift")) + #expect(!matchesFilePathFilter(relativePath: "Models/User.swift", pattern: "Models/Admin.swift")) + } + + @Test("File path: wildcard suffix in segment") + func filePathWildcardSuffix() { + #expect(matchesFilePathFilter(relativePath: "Models/User.swift", pattern: "Models/Us*")) + #expect(matchesFilePathFilter(relativePath: "Models/UserId.swift", pattern: "Models/Us*")) + #expect(!matchesFilePathFilter(relativePath: "Models/Admin.swift", pattern: "Models/Us*")) + } + + @Test("File path: star matches any single segment") + func filePathStarMatchesAnySegment() { + #expect(matchesFilePathFilter(relativePath: "Models/User.swift", pattern: "Models/*")) + #expect(matchesFilePathFilter(relativePath: "Models/Admin.swift", pattern: "Models/*")) + #expect(!matchesFilePathFilter(relativePath: "Models/Sub/Deep.swift", pattern: "Models/*")) + } + + @Test("File path: double star recursive wildcard") + func filePathDoubleStarRecursive() { + #expect(matchesFilePathFilter(relativePath: "Models/User.swift", pattern: "Models/**")) + #expect(matchesFilePathFilter(relativePath: "Models/Sub/Deep.swift", pattern: "Models/**")) + #expect(matchesFilePathFilter(relativePath: "Models/A/B/C.swift", pattern: "Models/**")) + #expect(!matchesFilePathFilter(relativePath: "Other/User.swift", pattern: "Models/**")) + } + + @Test("File path: double star matches zero segments") + func filePathDoubleStarMatchesZero() { + #expect(matchesFilePathFilter(relativePath: "Models/User.swift", pattern: "**/User.swift")) + #expect(matchesFilePathFilter(relativePath: "A/B/User.swift", pattern: "**/User.swift")) + #expect(matchesFilePathFilter(relativePath: "User.swift", pattern: "**/User.swift")) + } + + @Test("File path: double star in middle of pattern") + func filePathDoubleStarInMiddle() { + #expect(matchesFilePathFilter(relativePath: "A/B/C/User.swift", pattern: "A/**/User.swift")) + #expect(matchesFilePathFilter(relativePath: "A/User.swift", pattern: "A/**/User.swift")) + #expect(!matchesFilePathFilter(relativePath: "A/B/C/Admin.swift", pattern: "A/**/User.swift")) + } + + @Test("File path: no match when path is shorter than pattern") + func filePathNoMatchShorterPath() { + #expect(!matchesFilePathFilter(relativePath: "Models", pattern: "Models/User.swift")) + } + + @Test("File path: no match when path is longer than pattern without wildcards") + func filePathNoMatchLongerPath() { + #expect(!matchesFilePathFilter(relativePath: "Models/Sub/User.swift", pattern: "Models/User.swift")) + } + + // ==== ------------------------------------------------------------------- + // MARK: Type-name matching tests + + @Test("Type name: exact match") + func typeNameExactMatch() { + #expect(matchesTypeNameFilter(qualifiedName: "MyType", pattern: "MyType")) + #expect(!matchesTypeNameFilter(qualifiedName: "MyType", pattern: "OtherType")) + } + + @Test("Type name: nested type with dot separator") + func typeNameNested() { + #expect(matchesTypeNameFilter(qualifiedName: "Something.Other", pattern: "Something.Other")) + #expect(!matchesTypeNameFilter(qualifiedName: "Something.Other", pattern: "Something.Wrong")) + } + + @Test("Type name: wildcard suffix") + func typeNameWildcardSuffix() { + #expect(matchesTypeNameFilter(qualifiedName: "Something.Other", pattern: "Something.Ot*")) + #expect(!matchesTypeNameFilter(qualifiedName: "Something.Other", pattern: "Something.Wr*")) + } + + @Test("Type name: star matches any single component") + func typeNameStarMatchesAny() { + #expect(matchesTypeNameFilter(qualifiedName: "Something.Other", pattern: "Something.*")) + #expect(!matchesTypeNameFilter(qualifiedName: "A.B.C", pattern: "A.*")) + } + + @Test("Type name: double star recursive") + func typeNameDoubleStarRecursive() { + #expect(matchesTypeNameFilter(qualifiedName: "A.B.C", pattern: "A.**")) + #expect(matchesTypeNameFilter(qualifiedName: "A.B", pattern: "A.**")) + #expect(!matchesTypeNameFilter(qualifiedName: "B.C", pattern: "A.**")) + } + + @Test("Type name: double star matches zero components") + func typeNameDoubleStarZero() { + #expect(matchesTypeNameFilter(qualifiedName: "User", pattern: "**.User")) + #expect(matchesTypeNameFilter(qualifiedName: "A.B.User", pattern: "**.User")) + } + + // ==== ------------------------------------------------------------------- + // MARK: Pattern classification tests + + @Test("Pattern classification") + func patternClassification() { + #expect(classifyPattern("Models/User.swift") == .filePath) + #expect(classifyPattern("Models/**") == .filePath) + #expect(classifyPattern("Something.Other") == .typeName) + #expect(classifyPattern("MyType") == .plain) + #expect(classifyPattern("My*") == .plain) + } + + // ==== ------------------------------------------------------------------- + // MARK: shouldJExtractFile tests + + @Test("No filters means everything passes") + func noFilters() { + var config = Configuration() + #expect(shouldJExtractFile(relativePath: "Anything.swift", config: config)) + + config.swiftFilterInclude = [] + config.swiftFilterExclude = [] + #expect(shouldJExtractFile(relativePath: "Anything.swift", config: config)) + } + + @Test("File include filter only") + func fileIncludeOnly() { + var config = Configuration() + config.swiftFilterInclude = ["Models/**"] + + #expect(shouldJExtractFile(relativePath: "Models/User.swift", config: config)) + #expect(shouldJExtractFile(relativePath: "Models/Sub/Deep.swift", config: config)) + #expect(!shouldJExtractFile(relativePath: "Other/Thing.swift", config: config)) + } + + @Test("File exclude filter only") + func fileExcludeOnly() { + var config = Configuration() + config.swiftFilterExclude = ["Internal/*"] + + #expect(shouldJExtractFile(relativePath: "Models/User.swift", config: config)) + #expect(!shouldJExtractFile(relativePath: "Internal/Secret.swift", config: config)) + } + + @Test("File include and exclude combined") + func fileIncludeAndExclude() { + var config = Configuration() + config.swiftFilterInclude = ["Models/**"] + config.swiftFilterExclude = ["Models/Internal*"] + + #expect(shouldJExtractFile(relativePath: "Models/User.swift", config: config)) + #expect(!shouldJExtractFile(relativePath: "Models/InternalHelper.swift", config: config)) + #expect(!shouldJExtractFile(relativePath: "Other/Thing.swift", config: config)) + } + + @Test("Type-name patterns are ignored by shouldJExtractFile") + func typeNamePatternsIgnoredByFileFilter() { + var config = Configuration() + config.swiftFilterInclude = ["Something.Other"] + + // Type-name-only includes should not restrict file-level filtering + #expect(shouldJExtractFile(relativePath: "Anything.swift", config: config)) + } + + // ==== ------------------------------------------------------------------- + // MARK: shouldJExtractType tests + + @Test("No filters means all types pass") + func noFiltersAllTypesPass() { + let config = Configuration() + #expect(shouldJExtractType(qualifiedName: "Anything", config: config)) + #expect(shouldJExtractType(qualifiedName: "A.B.C", config: config)) + } + + @Test("Type include filter") + func typeIncludeFilter() { + var config = Configuration() + config.swiftFilterInclude = ["Something.Other"] + + #expect(shouldJExtractType(qualifiedName: "Something.Other", config: config)) + #expect(!shouldJExtractType(qualifiedName: "Something.Wrong", config: config)) + } + + @Test("Type exclude filter") + func typeExcludeFilter() { + var config = Configuration() + config.swiftFilterExclude = ["Something.Internal*"] + + #expect(shouldJExtractType(qualifiedName: "Something.Other", config: config)) + #expect(!shouldJExtractType(qualifiedName: "Something.InternalHelper", config: config)) + } + + @Test("File-path patterns are ignored by shouldJExtractType") + func filePathPatternsIgnoredByTypeFilter() { + var config = Configuration() + config.swiftFilterInclude = ["Models/**"] + + // File-path-only includes should not restrict type-level filtering + #expect(shouldJExtractType(qualifiedName: "Anything", config: config)) + } + + @Test("Plain pattern matches both file and type") + func plainPatternMatchesBoth() { + var config = Configuration() + config.swiftFilterInclude = ["MyType"] + + // Plain pattern works at file level (matched against filename segment) + #expect(shouldJExtractFile(relativePath: "MyType.swift", config: config)) + #expect(!shouldJExtractFile(relativePath: "OtherType.swift", config: config)) + + // Plain pattern works at type level + #expect(shouldJExtractType(qualifiedName: "MyType", config: config)) + #expect(!shouldJExtractType(qualifiedName: "OtherType", config: config)) + } + + @Test("Mixed file and type patterns in same config") + func mixedPatterns() { + var config = Configuration() + config.swiftFilterInclude = ["Models/**", "Something.Other"] + + // File filter applies the file-path pattern + #expect(shouldJExtractFile(relativePath: "Models/User.swift", config: config)) + #expect(!shouldJExtractFile(relativePath: "Other/Thing.swift", config: config)) + + // Type filter applies the type-name pattern + #expect(shouldJExtractType(qualifiedName: "Something.Other", config: config)) + #expect(!shouldJExtractType(qualifiedName: "Something.Wrong", config: config)) + } + + // ==== ------------------------------------------------------------------- + // MARK: Config JSON parsing tests + + @Test("jextract filters round-trip through JSON config") + func filtersFromJSON() throws { + let json = """ + { + "javaPackage": "com.example.swift", + "mode": "jni", + "swiftFilterInclude": ["Models/**", "Something.Other"], + "swiftFilterExclude": ["Models/Internal*"] + } + """ + let config = try readConfiguration(string: json, configPath: nil) + #expect(config != nil) + #expect(config?.swiftFilterInclude == ["Models/**", "Something.Other"]) + #expect(config?.swiftFilterExclude == ["Models/Internal*"]) + } + + @Test("Config without filters has nil filter fields") + func noFiltersInJSON() throws { + let json = """ + { + "javaPackage": "com.example.swift", + "mode": "jni" + } + """ + let config = try readConfiguration(string: json, configPath: nil) + #expect(config != nil) + #expect(config?.swiftFilterInclude == nil) + #expect(config?.swiftFilterExclude == nil) + } + + @Test("jextract and wrap-java filters are independent in config") + func independentFilters() throws { + let json = """ + { + "swiftFilterInclude": ["Models/**"], + "javaFilterInclude": ["org.apache.commons"] + } + """ + let config = try #require(try readConfiguration(string: json, configPath: nil)) + #expect(config.swiftFilterInclude == ["Models/**"]) + #expect(config.javaFilterInclude == ["org.apache.commons"]) + } +} From 25bf9a240392869f0f6fea4163c013151cc6f1ac Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Mon, 30 Mar 2026 11:18:36 +0900 Subject: [PATCH 2/2] fix formatting --- Sources/JExtractSwiftLib/Swift2Java.swift | 3 +- .../JExtractFileFilterTests.swift | 37 ++++++++++--------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Sources/JExtractSwiftLib/Swift2Java.swift b/Sources/JExtractSwiftLib/Swift2Java.swift index a7920958..5a1b2da2 100644 --- a/Sources/JExtractSwiftLib/Swift2Java.swift +++ b/Sources/JExtractSwiftLib/Swift2Java.swift @@ -54,8 +54,7 @@ public struct SwiftToJava { let allFiles = collectAllFiles(suffix: ".swift", in: inputPaths, log: translator.log) let hasFilters = - !(config.swiftFilterInclude ?? []).isEmpty || - !(config.swiftFilterExclude ?? []).isEmpty + !(config.swiftFilterInclude ?? []).isEmpty || !(config.swiftFilterExclude ?? []).isEmpty // Register files to the translator. let fileManager = FileManager.default diff --git a/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift b/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift index c8e77245..6b59a368 100644 --- a/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift +++ b/Tests/JExtractSwiftTests/JExtractFileFilterTests.swift @@ -12,10 +12,11 @@ // //===----------------------------------------------------------------------===// -@testable import JExtractSwiftLib import SwiftJavaConfigurationShared import Testing +@testable import JExtractSwiftLib + // ==== ----------------------------------------------------------------------- // MARK: File-path matching tests @@ -254,13 +255,13 @@ struct JExtractFileFilterTests { @Test("jextract filters round-trip through JSON config") func filtersFromJSON() throws { let json = """ - { - "javaPackage": "com.example.swift", - "mode": "jni", - "swiftFilterInclude": ["Models/**", "Something.Other"], - "swiftFilterExclude": ["Models/Internal*"] - } - """ + { + "javaPackage": "com.example.swift", + "mode": "jni", + "swiftFilterInclude": ["Models/**", "Something.Other"], + "swiftFilterExclude": ["Models/Internal*"] + } + """ let config = try readConfiguration(string: json, configPath: nil) #expect(config != nil) #expect(config?.swiftFilterInclude == ["Models/**", "Something.Other"]) @@ -270,11 +271,11 @@ struct JExtractFileFilterTests { @Test("Config without filters has nil filter fields") func noFiltersInJSON() throws { let json = """ - { - "javaPackage": "com.example.swift", - "mode": "jni" - } - """ + { + "javaPackage": "com.example.swift", + "mode": "jni" + } + """ let config = try readConfiguration(string: json, configPath: nil) #expect(config != nil) #expect(config?.swiftFilterInclude == nil) @@ -284,11 +285,11 @@ struct JExtractFileFilterTests { @Test("jextract and wrap-java filters are independent in config") func independentFilters() throws { let json = """ - { - "swiftFilterInclude": ["Models/**"], - "javaFilterInclude": ["org.apache.commons"] - } - """ + { + "swiftFilterInclude": ["Models/**"], + "javaFilterInclude": ["org.apache.commons"] + } + """ let config = try #require(try readConfiguration(string: json, configPath: nil)) #expect(config.swiftFilterInclude == ["Models/**"]) #expect(config.javaFilterInclude == ["org.apache.commons"])