// // PluginProtocol.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import Foundation import ZQData import ZQParser /// Declarative syntax specification for a user command: which qualifiers /// and options the engine should accept and pre-validate. public struct ZQSyntaxSpec: Equatable, Sendable { public var acceptsVarlist: Bool public var acceptsCondition: Bool public var acceptsRange: Bool public var acceptsWeight: Bool /// Known option names; empty means any option is passed through. public var options: [String] /// Whether the plugin may mutate the working dataset. Plugins without /// this receive a read-only view (CLAUDE.md §4). public var mutates: Bool public init( acceptsVarlist: Bool = true, acceptsCondition: Bool = true, acceptsRange: Bool = true, acceptsWeight: Bool = false, options: [String] = [], mutates: Bool = false ) { self.acceptsVarlist = acceptsVarlist self.acceptsCondition = acceptsCondition self.acceptsRange = acceptsRange self.acceptsWeight = acceptsWeight self.options = options self.mutates = mutates } } /// Result of executing a command: rendered console text plus named scalar /// results (Stata's `r()` analog). Mutating plugins return the new /// working dataset via `replacementFrame`; the engine installs it only /// when the plugin's syntax declares `mutates: true`. public struct ZQResult: Equatable, Sendable { public var text: String public var scalars: [String: Double] public var replacementFrame: ZQDataFrame? public init( text: String, scalars: [String: Double] = [:], replacementFrame: ZQDataFrame? = nil ) { self.text = text self.scalars = scalars self.replacementFrame = replacementFrame } } /// Execution context handed to a plugin: the parsed command and a value /// copy of the working dataset — mutations stay local unless returned /// through `ZQResult.replacementFrame` by a `mutates: true` plugin. public struct ZQContext: Sendable { public var command: ZQCommand public var frame: ZQDataFrame public init(command: ZQCommand, frame: ZQDataFrame) { self.command = command self.frame = frame } } /// Native plugin protocol (CLAUDE.md §4). Conforming Swift packages are /// discovered under `~/Library/Application Support/Metrika/Commands/`. public protocol ZQCommandPlugin: Sendable { static var verb: String { get } static var syntax: ZQSyntaxSpec { get } func execute(_ ctx: ZQContext) async throws -> ZQResult } /// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of /// ZQL lines the engine replays. An optional leading `args name1 name2 …` /// declaration names positional arguments; script lines reference them /// Stata-style: `` `1' ``, `` `name' ``, and `` `0' `` (everything). public struct ZQScriptCommand: Equatable, Sendable { public var verb: String public var fileURL: URL /// Body lines with the `args` declaration already stripped. public var lines: [String] /// Names from the `args` declaration, in positional order. public var parameterNames: [String] public init(verb: String, fileURL: URL, lines: [String]) { self.verb = verb self.fileURL = fileURL // Peel a leading `args` declaration (blank/comment lines may // precede it). var parameterNames: [String] = [] var body = lines for (index, line) in lines.enumerated() { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty || trimmed.hasPrefix("//") || trimmed.hasPrefix("*") { continue } if trimmed.hasPrefix("args ") || trimmed.hasPrefix("args\t") { parameterNames = trimmed.dropFirst(4) .split(separator: " ", omittingEmptySubsequences: true) .map(String.init) body.remove(at: index) } break } self.lines = body self.parameterNames = parameterNames } /// Substitutes `` `0' ``, `` `k' ``, and `` `name' `` macros with the /// invocation arguments (whitespace-split remainder of the command). public func expandedLines(arguments rawArguments: String) -> [String] { let arguments = rawArguments .split(separator: " ", omittingEmptySubsequences: true) .map(String.init) var substitutions: [String: String] = ["0": rawArguments] for (index, value) in arguments.enumerated() { substitutions["\(index + 1)"] = value if index < parameterNames.count { substitutions[parameterNames[index]] = value } } // Declared but unsupplied parameters expand to nothing. for name in parameterNames where substitutions[name] == nil { substitutions[name] = "" } return lines.map { line in var expanded = line for (key, value) in substitutions { expanded = expanded.replacingOccurrences(of: "`\(key)'", with: value) } return expanded } } } /// Immutable registry of native plugins, verb-keyed. Built once at session /// start; verbs that would shadow a built-in are rejected outright. public struct ZQPluginRegistry: Sendable { public struct Registered: Sendable { public let verb: String public let syntax: ZQSyntaxSpec public let plugin: any ZQCommandPlugin } public struct ShadowingError: Error, CustomStringConvertible { public let verb: String public var description: String { "plugin verb '\(verb)' shadows a built-in command" } } private var byVerb: [String: Registered] = [:] public init( plugins: [any ZQCommandPlugin], reservedVerbs: Set ) throws { for plugin in plugins { let type = type(of: plugin) let verb = type.verb guard !reservedVerbs.contains(verb) else { throw ShadowingError(verb: verb) } byVerb[verb] = Registered(verb: verb, syntax: type.syntax, plugin: plugin) } } public var verbs: [String] { Array(byVerb.keys) } public var isEmpty: Bool { byVerb.isEmpty } public subscript(verb: String) -> Registered? { byVerb[verb] } /// Pre-execution validation of a parsed command against the plugin's /// declared syntax. public func validate(_ command: ZQCommand, against syntax: ZQSyntaxSpec) -> String? { if !syntax.acceptsVarlist && !command.varlist.isEmpty { return "does not accept a varlist" } if !syntax.acceptsCondition && command.condition != nil { return "does not accept an 'if' qualifier" } if !syntax.acceptsRange && command.range != nil { return "does not accept an 'in' range" } if !syntax.acceptsWeight && command.weight != nil { return "does not accept weights" } if !syntax.options.isEmpty { for option in command.options where !syntax.options.contains(option.name) { return "option '\(option.name)' not allowed" } } return nil } } /// Discovers `.zyq` script commands in the user commands directory. public enum ZQPluginDiscovery { public static var defaultDirectory: URL { FileManager.default .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Metrika/Commands", isDirectory: true) } public static func scriptCommands( in directory: URL = defaultDirectory ) -> [ZQScriptCommand] { guard let contents = try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: nil ) else { return [] } return contents .filter { $0.pathExtension == "zyq" } .compactMap { url in guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } let lines = text.split( separator: "\n", omittingEmptySubsequences: false ).map(String.init) return ZQScriptCommand( verb: url.deletingPathExtension().lastPathComponent, fileURL: url, lines: lines ) } .sorted { $0.verb < $1.verb } } }