SPB Git

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%
8.6 KB · 244 lines swift
Raw Blame History
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