Skip to content
Merged
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
123 changes: 123 additions & 0 deletions Sources/Hypercode/Explain/Explainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/// The cascade trace for one resolved property on one matched node.
public struct PropertyTrace: Sendable {
public let key: String
public let winner: Match
public let losers: [Match]
}

/// All cascade traces for one node matched by the explain query.
public struct NodeTrace: Sendable {
/// Human-readable selector path from the root, e.g. "Service > Database".
public let nodePath: String
/// One trace per property (filtered to the queried property if provided).
public let properties: [PropertyTrace]
}

/// Walks a resolved tree to produce cascade traces for every node that matches
/// a given selector. Used by `hypercode explain`.
public struct Explainer {
private let commands: [Command]
private let resolved: [ResolvedNode]

public init(commands: [Command], resolved: [ResolvedNode]) {
self.commands = commands
self.resolved = resolved
}

/// Returns cascade traces for every node matching `selector`.
/// - Parameters:
/// - selector: The selector to match against the tree.
/// - property: If non-nil, only include this property key in each trace.
public func explain(selector: Selector, property: String?) -> [NodeTrace] {
var results: [NodeTrace] = []
walk(
commands: commands, resolved: resolved,
ancestors: [], pathComponents: [],
selector: selector, property: property,
into: &results
)
return results
}

// MARK: - Private tree walk

private func walk(
commands: [Command],
resolved: [ResolvedNode],
ancestors: [Command],
pathComponents: [String],
selector: Selector,
property: String?,
into results: inout [NodeTrace]
) {
// The resolver preserves tree shape, so the two arrays are parallel by
// construction — fail fast in debug builds if a caller breaks that.
assert(commands.count == resolved.count,
"command/resolved tree shape mismatch (\(commands.count) vs \(resolved.count))")
for (cmd, node) in zip(commands, resolved) {
let context = NodeContext(node: cmd, ancestors: ancestors)
let nodeLabel = nodeLabel(cmd)
let path = (pathComponents + [nodeLabel]).joined(separator: " > ")

if selectorSpec(selector).isSatisfiedBy(context) {
let traces = propertyTraces(node: node, filter: property)
results.append(NodeTrace(nodePath: path, properties: traces))
}

walk(
commands: cmd.children, resolved: node.children,
ancestors: ancestors + [cmd], pathComponents: pathComponents + [nodeLabel],
selector: selector, property: property,
into: &results
)
}
}

private func propertyTraces(node: ResolvedNode, filter: String?) -> [PropertyTrace] {
let keys = node.properties.keys.sorted()
return keys.compactMap { key in
guard filter == nil || key == filter else { return nil }
let rv = node.properties[key]!
return PropertyTrace(key: key, winner: rv.winner, losers: rv.losers)
}
}

private func nodeLabel(_ cmd: Command) -> String {
var label = cmd.type
if let c = cmd.className { label += ".\(c)" }
if let i = cmd.id { label += "#\(i)" }
return label
}
}

// MARK: - Text rendering

extension NodeTrace {
public func renderText() -> String {
var out = ""
for trace in properties {
out += " \(trace.key)\n"
out += renderMatch(trace.winner, role: "WINNER")
if !trace.losers.isEmpty {
out += " \(String(repeating: "─", count: 20))\n"
for loser in trace.losers {
out += renderMatch(loser, role: "losing")
}
}
}
return out
}

private func renderMatch(_ m: Match, role: String) -> String {
// " WINNER selector { value: X }"
// " file: f line: N specificity: (i,c,t) order: N"
let prefix = " " + role.padding(toLength: 7, withPad: " ", startingAt: 0) + " "
let cont = String(repeating: " ", count: prefix.count)
let spec = "(\(m.specificity.ids),\(m.specificity.classes),\(m.specificity.types))"
var line1 = prefix + "\(m.selector) { value: \(m.value.rawString) }\n"
var line2 = cont
if let f = m.file { line2 += "file: \(f) " }
line2 += "line: \(m.line) specificity: \(spec) order: \(m.order)\n"
return line1 + line2
}
}
7 changes: 7 additions & 0 deletions Sources/Hypercode/HCS/CascadeSheetReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ public struct CascadeSheetReader {
s.count >= 2 && ((s.first == "\"" && s.last == "\"") || (s.first == "'" && s.last == "'"))
}

// MARK: - Public selector parsing (used by `hypercode explain`)

/// Parse a CSS-like selector string from user input (e.g. `service > database`).
public func parseSelector(fromString text: String) throws -> Selector {
try parseSelector(text, line: 1)
}

// MARK: - Selectors & guards

private func parseGuard(_ text: String, line: Int) throws -> ContextGuard {
Expand Down
70 changes: 70 additions & 0 deletions Sources/HypercodeCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ usage:
hypercode validate <file.hc> [--hcs <file.hcs>]
hypercode resolve <file.hc> --hcs <file.hcs> [--ctx key=value]...
hypercode emit <file.hc> [--hcs <file.hcs>] [--ctx key=value]... [--format json|yaml] [--ir-version 1|2]
hypercode explain <file.hc> --hcs <file.hcs> [--ctx key=value]... <selector> [property]
hypercode lsp # language server (LSP over stdio)

global: [--diagnostics text|json]
Expand Down Expand Up @@ -179,6 +180,73 @@ func runEmit(_ args: [String]) throws {
print(Emitter().emit(resolved, version: irVersion, context: context, as: format), terminator: "")
}

func runExplain(_ args: [String]) throws {
var hcPath: String?
var hcsPath: String?
var context: ResolutionContext = [:]
var positional: [String] = []

var index = 0
while index < args.count {
switch args[index] {
case "--hcs":
index += 1
guard index < args.count else { fail("error: --hcs needs a path") }
hcsPath = args[index]
case "--ctx":
index += 1
guard index < args.count else { fail("error: --ctx needs key=value") }
let (key, value) = parseContextAssignment(args[index])
context[key] = value
default:
if hcPath == nil { hcPath = args[index] } else { positional.append(args[index]) }
}
index += 1
}

guard let hcPath else { fail("error: explain needs a .hc file\n\n\(usage)", code: 64) }
guard let hcsPath else { fail("error: explain needs --hcs <file.hcs>\n\n\(usage)", code: 64) }
guard !positional.isEmpty else { fail("error: explain needs a <selector>\n\n\(usage)", code: 64) }
guard positional.count <= 2 else {
fail("error: unexpected argument '\(positional[2])' — explain takes <selector> [property]\n\n\(usage)", code: 64)
}

let selectorText = positional[0]
let propertyFilter: String? = positional.count == 2 ? positional[1] : nil

let selectorParsed: Hypercode.Selector
do {
selectorParsed = try CascadeSheetReader().parseSelector(fromString: selectorText)
} catch {
fail("error: invalid selector '\(selectorText)': \(error)")
}

let commands = try Parser(source: readSource(hcPath)).parse()
let sheet = try CascadeSheetReader().read(readSource(hcsPath), file: hcsPath)
let resolved = Resolver(sheet: sheet, context: context).resolve(commands)
let traces = Explainer(commands: commands, resolved: resolved).explain(
selector: selectorParsed, property: propertyFilter
)

if traces.isEmpty {
let msg = "no nodes matched selector '\(selectorText)'"
FileHandle.standardError.write(Data((msg + "\n").utf8))
exit(1)
}

let countStr = traces.count == 1 ? "1 node" : "\(traces.count) nodes"
print("Matched \(countStr) for selector '\(selectorText)'\n")
for trace in traces {
print("Node: \(trace.nodePath)")
if trace.properties.isEmpty {
print(" (no resolved properties)")
} else {
print(trace.renderText(), terminator: "")
}
print("")
}
}

// Pull the global `--diagnostics <format>` flag out of the argument list.
let arguments: [String] = {
let raw = Array(CommandLine.arguments.dropFirst())
Expand Down Expand Up @@ -213,6 +281,8 @@ do {
try runValidate(Array(arguments.dropFirst()))
case "emit":
try runEmit(Array(arguments.dropFirst()))
case "explain":
try runExplain(Array(arguments.dropFirst()))
case "lsp":
LSPServer().run()
case "parse":
Expand Down
126 changes: 126 additions & 0 deletions Tests/HypercodeTests/ExplainTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import XCTest
@testable import Hypercode

/// Verifies the `Explainer` cascade trace using the RFC §5 service example.
final class ExplainTests: XCTestCase {
private let hc = """
Service
Logger.console
Database#main-db
Connect
APIServer
Listen
"""

private let hcs = """
Logger:
level: "debug"
.console:
format: "text"
Database:
driver: "sqlite"
file: "dev.sqlite3"
APIServer > Listen:
host: "127.0.0.1"
port: 5000
@env[production]:
Logger:
level: "info"
.console:
format: "json"
'#main-db':
driver: "postgres"
pool_size: 50
APIServer > Listen:
host: "0.0.0.0"
port: 8080
"""

private func explainer(env: String? = nil) throws -> Explainer {
let commands = try Parser(source: hc).parse()
let sheet = try CascadeSheetReader().read(hcs)
let context: ResolutionContext = env.map { ["env": $0] } ?? [:]
let resolved = Resolver(sheet: sheet, context: context).resolve(commands)
return Explainer(commands: commands, resolved: resolved)
}

func testSinglePropertyExplainDev() throws {
let ex = try explainer()
let traces = ex.explain(
selector: try CascadeSheetReader().parseSelector(fromString: "Database"),
property: "driver"
)
XCTAssertEqual(traces.count, 1)
let trace = traces[0]
XCTAssertEqual(trace.nodePath, "Service > Database#main-db")
XCTAssertEqual(trace.properties.count, 1)
let prop = trace.properties[0]
XCTAssertEqual(prop.key, "driver")
XCTAssertEqual(prop.winner.value, .string("sqlite"))
XCTAssertEqual(prop.winner.selector, .type("Database"))
XCTAssertTrue(prop.losers.isEmpty)
}

func testSinglePropertyExplainProduction() throws {
let ex = try explainer(env: "production")
let traces = ex.explain(
selector: try CascadeSheetReader().parseSelector(fromString: "Database"),
property: "driver"
)
XCTAssertEqual(traces.count, 1)
let prop = traces[0].properties[0]
// #main-db beats Database by specificity
XCTAssertEqual(prop.winner.value, .string("postgres"))
XCTAssertEqual(prop.winner.selector, .id("main-db"))
// The losing Database rule should be retained
XCTAssertEqual(prop.losers.count, 1)
XCTAssertEqual(prop.losers[0].value, .string("sqlite"))
XCTAssertEqual(prop.losers[0].selector, .type("Database"))
}

func testChildSelectorExplain() throws {
let ex = try explainer(env: "production")
let traces = ex.explain(
selector: try CascadeSheetReader().parseSelector(fromString: "APIServer > Listen"),
property: "port"
)
XCTAssertEqual(traces.count, 1)
let prop = traces[0].properties[0]
XCTAssertEqual(prop.winner.value, .int(8080))
}

func testNoMatchReturnsEmpty() throws {
let ex = try explainer()
let traces = ex.explain(
selector: try CascadeSheetReader().parseSelector(fromString: "NonExistent"),
property: nil
)
XCTAssertTrue(traces.isEmpty)
}

func testAllPropertiesWhenFilterNil() throws {
let ex = try explainer()
let traces = ex.explain(
selector: try CascadeSheetReader().parseSelector(fromString: "Database"),
property: nil
)
XCTAssertEqual(traces.count, 1)
// Database has driver and file in dev context
let keys = Set(traces[0].properties.map(\.key))
XCTAssertEqual(keys, ["driver", "file"])
}

func testTextRenderingContainsExpectedLines() throws {
let ex = try explainer(env: "production")
let traces = ex.explain(
selector: try CascadeSheetReader().parseSelector(fromString: "Database"),
property: "driver"
)
let text = traces[0].renderText()
XCTAssertTrue(text.contains("driver"))
XCTAssertTrue(text.contains("WINNER"))
XCTAssertTrue(text.contains("postgres"))
XCTAssertTrue(text.contains("losing"))
XCTAssertTrue(text.contains("sqlite"))
}
}
Loading