diff --git a/README.md b/README.md index 60804ac..5dd202b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ aequery [--json | --text | --applescript | --chevron] [--flatten] [--unique] [-- | `--dry-run` | Parse and resolve only, do not send Apple Events | | `--sdef` | Print the SDEF definition for the resolved element or property | | `--find-paths` | Find all valid paths from the application root to the target | +| `--children` | List the possible next components for an inomplete path | | `--sdef-file ` | Load SDEF from a file path instead of from the application bundle | ## Expression Syntax diff --git a/Sources/AEQueryLib/SDEF/SDEFResolver.swift b/Sources/AEQueryLib/SDEF/SDEFResolver.swift index 6cc7ae1..c40033e 100644 --- a/Sources/AEQueryLib/SDEF/SDEFResolver.swift +++ b/Sources/AEQueryLib/SDEF/SDEFResolver.swift @@ -239,6 +239,32 @@ public struct SDEFResolver { return .classInfo(classDetail(currentClass)) } + /// List the possible child steps from the node addressed by the query. + /// If the query ends at a non-class-typed property, there are no children. + public func childrenInfo(for query: AEQuery) throws -> SDEFChildrenInfo { + guard let appClass = dictionary.findClass("application") else { + throw ResolverError.missingApplicationClass + } + + let targetClass: ClassDef + if query.steps.isEmpty { + targetClass = appClass + } else { + let resolved = try resolve(query) + guard let finalStep = resolved.steps.last else { + targetClass = appClass + return childrenInfo(forClass: targetClass) + } + guard let className = finalStep.className, + let cls = dictionary.findClass(className) else { + return SDEFChildrenInfo(inClass: nil, elements: [], properties: []) + } + targetClass = cls + } + + return childrenInfo(forClass: targetClass) + } + private func classDetail(_ cls: ClassDef) -> ClassDetail { let allProps = dictionary.allProperties(for: cls) let allElems = dictionary.allElements(for: cls) @@ -255,6 +281,43 @@ public struct SDEFResolver { elements: elementNames ) } + + private func childrenInfo(forClass cls: ClassDef) -> SDEFChildrenInfo { + let allProps = dictionary.allProperties(for: cls) + let allElems = dictionary.allElements(for: cls) + + var seenElements = Set() + let elements = allElems.compactMap { elem -> SDEFChildElement? in + guard !elem.hidden, let elemClass = dictionary.findClass(elem.type), !elemClass.hidden else { return nil } + let stepName = elemClass.pluralName ?? elemClass.name + let dedupeKey = "\(stepName.lowercased())|\(elemClass.code)" + guard seenElements.insert(dedupeKey).inserted else { return nil } + return SDEFChildElement( + stepName: stepName, + className: elemClass.name, + code: elemClass.code + ) + }.sorted { lhs, rhs in + lhs.stepName.localizedCaseInsensitiveCompare(rhs.stepName) == .orderedAscending + } + + var seenProperties = Set() + let properties = allProps.compactMap { prop -> SDEFChildProperty? in + guard !prop.hidden else { return nil } + let dedupeKey = "\(prop.name.lowercased())|\(prop.code)" + guard seenProperties.insert(dedupeKey).inserted else { return nil } + return SDEFChildProperty( + name: prop.name, + code: prop.code, + type: prop.type, + access: prop.access + ) + }.sorted { lhs, rhs in + lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + + return SDEFChildrenInfo(inClass: cls.name, elements: elements, properties: properties) + } } public enum SDEFInfo { @@ -279,6 +342,44 @@ public struct PropertyDetail { public let inClass: String } +public struct SDEFChildrenInfo { + public let inClass: String? + public let elements: [SDEFChildElement] + public let properties: [SDEFChildProperty] + + public init(inClass: String?, elements: [SDEFChildElement], properties: [SDEFChildProperty]) { + self.inClass = inClass + self.elements = elements + self.properties = properties + } +} + +public struct SDEFChildElement: Equatable { + public let stepName: String + public let className: String + public let code: String + + public init(stepName: String, className: String, code: String) { + self.stepName = stepName + self.className = className + self.code = code + } +} + +public struct SDEFChildProperty: Equatable { + public let name: String + public let code: String + public let type: String? + public let access: PropertyAccess? + + public init(name: String, code: String, type: String?, access: PropertyAccess?) { + self.name = name + self.code = code + self.type = type + self.access = access + } +} + public enum ResolverError: Error, LocalizedError, Equatable { case missingApplicationClass case unknownElement(String, inClass: String) diff --git a/Sources/aequery/AEQueryCommand.swift b/Sources/aequery/AEQueryCommand.swift index 5b66cbc..5f5275c 100644 --- a/Sources/aequery/AEQueryCommand.swift +++ b/Sources/aequery/AEQueryCommand.swift @@ -43,6 +43,9 @@ struct AEQueryCommand: ParsableCommand { @Flag(name: .long, help: "Find all valid paths from the application root to the target") var findPaths: Bool = false + @Flag(name: .long, help: "List the current possible child elements and properties at the path") + var children: Bool = false + @Option(name: .long, help: "Load SDEF from a file path instead of from the application bundle") var sdefFile: String? = nil @@ -136,6 +139,12 @@ struct AEQueryCommand: ParsableCommand { return } + if children { + let info = try resolver.childrenInfo(for: query) + print(formatChildrenInfo(info, query: query)) + return + } + if dryRun { FileHandle.standardError.write("Dry run: parsed and resolved successfully.\n") return @@ -250,6 +259,48 @@ func formatSDEFInfo(_ info: SDEFInfo) -> String { } } +func formatChildrenInfo(_ info: SDEFChildrenInfo, query: AEQuery) -> String { + var lines: [String] = [] + let path = query.steps.isEmpty + ? "/\(query.appName)" + : "/\(query.appName)/" + query.steps.map(\.name).joined(separator: "/") + + if let className = info.inClass { + lines.append("children at \(path) (class \(className))") + } else { + lines.append("children at \(path) (no class-typed node)") + } + + lines.append(" elements:") + if info.elements.isEmpty { + lines.append(" (none)") + } else { + for elem in info.elements { + lines.append(" \(elem.stepName) '\(elem.code)' : \(elem.className)") + } + } + + lines.append(" properties:") + if info.properties.isEmpty { + lines.append(" (none)") + } else { + for prop in info.properties { + var line = " \(prop.name) '\(prop.code)'" + if let type = prop.type { line += " : \(type)" } + if let access = prop.access { + switch access { + case .readOnly: line += " [r]" + case .readWrite: line += " [rw]" + case .writeOnly: line += " [w]" + } + } + lines.append(line) + } + } + + return lines.joined(separator: "\n") +} + extension FileHandle { func write(_ string: String) { if let data = string.data(using: .utf8) { diff --git a/Tests/AEQueryLibTests/SDEFResolverTests.swift b/Tests/AEQueryLibTests/SDEFResolverTests.swift index ab0cdb2..065688e 100644 --- a/Tests/AEQueryLibTests/SDEFResolverTests.swift +++ b/Tests/AEQueryLibTests/SDEFResolverTests.swift @@ -204,4 +204,37 @@ struct SDEFResolverTests { #expect(r.steps[0].code == "cwin") #expect(r.steps[0].usedPluralForm == true) } + + // MARK: - Children listing + + private func childrenInfo(_ expression: String) throws -> SDEFChildrenInfo { + var lexer = Lexer(expression) + let tokens = try lexer.tokenize() + var parser = Parser(tokens: tokens) + let query = try parser.parse() + let dict = try SDEFParser().parse(xmlString: sdef) + return try SDEFResolver(dictionary: dict).childrenInfo(for: query) + } + + @Test func testChildrenAtApplication() throws { + let info = try childrenInfo("/App") + #expect(info.inClass == "application") + #expect(info.elements.map(\.stepName).contains("windows")) + #expect(info.elements.map(\.stepName).contains("documents")) + #expect(info.properties.map(\.name).contains("name")) + } + + @Test func testChildrenAtNestedElementPath() throws { + let info = try childrenInfo("/App/windows") + #expect(info.inClass == "window") + #expect(info.elements.map(\.stepName).contains("documents")) + #expect(info.properties.map(\.name).contains("index")) + } + + @Test func testChildrenAtScalarPropertyPathIsEmpty() throws { + let info = try childrenInfo("/App/windows/name") + #expect(info.inClass == nil) + #expect(info.elements.isEmpty) + #expect(info.properties.isEmpty) + } }