spb/metrika Public
Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.
Swift 92.4%
HTML 3.3%
R 3%
Shell 1.3%
1//2// PluginProtocol.swift3// Metrika4//5// Author: Simon-Pierre Boucher6// Contact: contact@spboucher.ai7// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation11import ZQData12import ZQParser1314/// Declarative syntax specification for a user command: which qualifiers15/// and options the engine should accept and pre-validate.16public struct ZQSyntaxSpec: Equatable, Sendable {17 public var acceptsVarlist: Bool18 public var acceptsCondition: Bool19 public var acceptsRange: Bool20 public var acceptsWeight: Bool21 /// Known option names; empty means any option is passed through.22 public var options: [String]23 /// Whether the plugin may mutate the working dataset. Plugins without24 /// this receive a read-only view (CLAUDE.md §4).25 public var mutates: Bool2627 public init(28 acceptsVarlist: Bool = true,29 acceptsCondition: Bool = true,30 acceptsRange: Bool = true,31 acceptsWeight: Bool = false,32 options: [String] = [],33 mutates: Bool = false34 ) {35 self.acceptsVarlist = acceptsVarlist36 self.acceptsCondition = acceptsCondition37 self.acceptsRange = acceptsRange38 self.acceptsWeight = acceptsWeight39 self.options = options40 self.mutates = mutates41 }42}4344/// Result of executing a command: rendered console text plus named scalar45/// results (Stata's `r()` analog). Mutating plugins return the new46/// working dataset via `replacementFrame`; the engine installs it only47/// when the plugin's syntax declares `mutates: true`.48public struct ZQResult: Equatable, Sendable {49 public var text: String50 public var scalars: [String: Double]51 public var replacementFrame: ZQDataFrame?5253 public init(54 text: String,55 scalars: [String: Double] = [:],56 replacementFrame: ZQDataFrame? = nil57 ) {58 self.text = text59 self.scalars = scalars60 self.replacementFrame = replacementFrame61 }62}6364/// Execution context handed to a plugin: the parsed command and a value65/// copy of the working dataset — mutations stay local unless returned66/// through `ZQResult.replacementFrame` by a `mutates: true` plugin.67public struct ZQContext: Sendable {68 public var command: ZQCommand69 public var frame: ZQDataFrame7071 public init(command: ZQCommand, frame: ZQDataFrame) {72 self.command = command73 self.frame = frame74 }75}7677/// Native plugin protocol (CLAUDE.md §4). Conforming Swift packages are78/// discovered under `~/Library/Application Support/Metrika/Commands/`.79public protocol ZQCommandPlugin: Sendable {80 static var verb: String { get }81 static var syntax: ZQSyntaxSpec { get }82 func execute(_ ctx: ZQContext) async throws -> ZQResult83}8485/// Tier-1 user command: a `.zyq` script (ado-file analog) — a sequence of86/// ZQL lines the engine replays. An optional leading `args name1 name2 …`87/// declaration names positional arguments; script lines reference them88/// Stata-style: `` `1' ``, `` `name' ``, and `` `0' `` (everything).89public struct ZQScriptCommand: Equatable, Sendable {90 public var verb: String91 public var fileURL: URL92 /// Body lines with the `args` declaration already stripped.93 public var lines: [String]94 /// Names from the `args` declaration, in positional order.95 public var parameterNames: [String]9697 public init(verb: String, fileURL: URL, lines: [String]) {98 self.verb = verb99 self.fileURL = fileURL100101 // Peel a leading `args` declaration (blank/comment lines may102 // precede it).103 var parameterNames: [String] = []104 var body = lines105 for (index, line) in lines.enumerated() {106 let trimmed = line.trimmingCharacters(in: .whitespaces)107 if trimmed.isEmpty || trimmed.hasPrefix("//") || trimmed.hasPrefix("*") {108 continue109 }110 if trimmed.hasPrefix("args ") || trimmed.hasPrefix("args\t") {111 parameterNames = trimmed.dropFirst(4)112 .split(separator: " ", omittingEmptySubsequences: true)113 .map(String.init)114 body.remove(at: index)115 }116 break117 }118 self.lines = body119 self.parameterNames = parameterNames120 }121122 /// Substitutes `` `0' ``, `` `k' ``, and `` `name' `` macros with the123 /// invocation arguments (whitespace-split remainder of the command).124 public func expandedLines(arguments rawArguments: String) -> [String] {125 let arguments = rawArguments126 .split(separator: " ", omittingEmptySubsequences: true)127 .map(String.init)128 var substitutions: [String: String] = ["0": rawArguments]129 for (index, value) in arguments.enumerated() {130 substitutions["\(index + 1)"] = value131 if index < parameterNames.count {132 substitutions[parameterNames[index]] = value133 }134 }135 // Declared but unsupplied parameters expand to nothing.136 for name in parameterNames where substitutions[name] == nil {137 substitutions[name] = ""138 }139140 return lines.map { line in141 var expanded = line142 for (key, value) in substitutions {143 expanded = expanded.replacingOccurrences(of: "`\(key)'", with: value)144 }145 return expanded146 }147 }148}149150/// Immutable registry of native plugins, verb-keyed. Built once at session151/// start; verbs that would shadow a built-in are rejected outright.152public struct ZQPluginRegistry: Sendable {153 public struct Registered: Sendable {154 public let verb: String155 public let syntax: ZQSyntaxSpec156 public let plugin: any ZQCommandPlugin157 }158159 public struct ShadowingError: Error, CustomStringConvertible {160 public let verb: String161 public var description: String {162 "plugin verb '\(verb)' shadows a built-in command"163 }164 }165166 private var byVerb: [String: Registered] = [:]167168 public init(169 plugins: [any ZQCommandPlugin],170 reservedVerbs: Set<String>171 ) throws {172 for plugin in plugins {173 let type = type(of: plugin)174 let verb = type.verb175 guard !reservedVerbs.contains(verb) else {176 throw ShadowingError(verb: verb)177 }178 byVerb[verb] = Registered(verb: verb, syntax: type.syntax, plugin: plugin)179 }180 }181182 public var verbs: [String] { Array(byVerb.keys) }183 public var isEmpty: Bool { byVerb.isEmpty }184185 public subscript(verb: String) -> Registered? { byVerb[verb] }186187 /// Pre-execution validation of a parsed command against the plugin's188 /// declared syntax.189 public func validate(_ command: ZQCommand, against syntax: ZQSyntaxSpec) -> String? {190 if !syntax.acceptsVarlist && !command.varlist.isEmpty {191 return "does not accept a varlist"192 }193 if !syntax.acceptsCondition && command.condition != nil {194 return "does not accept an 'if' qualifier"195 }196 if !syntax.acceptsRange && command.range != nil {197 return "does not accept an 'in' range"198 }199 if !syntax.acceptsWeight && command.weight != nil {200 return "does not accept weights"201 }202 if !syntax.options.isEmpty {203 for option in command.options where !syntax.options.contains(option.name) {204 return "option '\(option.name)' not allowed"205 }206 }207 return nil208 }209}210211/// Discovers `.zyq` script commands in the user commands directory.212public enum ZQPluginDiscovery {213 public static var defaultDirectory: URL {214 FileManager.default215 .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]216 .appendingPathComponent("Metrika/Commands", isDirectory: true)217 }218219 public static func scriptCommands(220 in directory: URL = defaultDirectory221 ) -> [ZQScriptCommand] {222 guard let contents = try? FileManager.default.contentsOfDirectory(223 at: directory, includingPropertiesForKeys: nil224 ) else { return [] }225226 return contents227 .filter { $0.pathExtension == "zyq" }228 .compactMap { url in229 guard let text = try? String(contentsOf: url, encoding: .utf8) else {230 return nil231 }232 let lines = text.split(233 separator: "\n", omittingEmptySubsequences: false234 ).map(String.init)235 return ZQScriptCommand(236 verb: url.deletingPathExtension().lastPathComponent,237 fileURL: url,238 lines: lines239 )240 }241 .sorted { $0.verb < $1.verb }242 }243}244