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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` | Load SDEF from a file path instead of from the application bundle |

## Expression Syntax
Expand Down
101 changes: 101 additions & 0 deletions Sources/AEQueryLib/SDEF/SDEFResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<String>()
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<String>()
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 {
Expand All @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions Sources/aequery/AEQueryCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions Tests/AEQueryLibTests/SDEFResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}