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%
20.4 KB · 561 lines swift
Raw Blame History
1//2//  CommandParser.swift3//  Metrika4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.8//910import Foundation1112/// Parser for one ZQL command line:13///14///     command [varlist] [if expr] [in range] [weight] [, options]15///16/// Prefix commands (`bootstrap, reps(1000): reg y x`) recurse on the text17/// after the colon. File-taking verbs (`use`, `save`, …) treat everything18/// before the first top-level comma as a raw path.19public struct ZQCommandParser: Sendable {20    public let verbTable: ZQVerbTable2122    public init(verbTable: ZQVerbTable = .builtin) {23        self.verbTable = verbTable24    }2526    /// Parses a standalone expression (used by the data browser's filter27    /// bar, which compiles its text to an `if` condition — CLAUDE.md §7).28    public func parseExpression(_ text: String) throws(ZQParseError) -> ZQExpression {29        let lexer = ZQLexer()30        let tokens = try lexer.tokenize(text)31        var cursor = ExpressionParser(tokens: tokens)32        let expression = try cursor.parseExpression()33        guard cursor.isKind(.endOfLine) else {34            throw ZQParseError(35                message: "unexpected \(cursor.current.kind.describe) after expression",36                column: cursor.current.column37            )38        }39        return expression40    }4142    /// Parses a single line. Returns nil for blank lines and comments.43    public func parse(_ line: String) throws(ZQParseError) -> ZQCommand? {44        let trimmed = line.trimmingCharacters(in: .whitespaces)45        if trimmed.isEmpty || trimmed.hasPrefix("//") || trimmed.hasPrefix("*") {46            return nil47        }4849        // Extract the verb word to decide the parsing strategy.50        let verbWord = String(trimmed.prefix { !$0.isWhitespace && $0 != "," })51        let verbColumn = columnOf(word: verbWord, in: line)52        guard let verb = verbTable.resolve(verbWord.lowercased()) else {53            throw ZQParseError(54                message: "unknown command '\(verbWord)'",55                column: verbColumn,56                suggestion: verbTable.closest(to: verbWord.lowercased())57            )58        }5960        if verbTable.prefixVerbs.contains(verb) {61            return try parsePrefixCommand(verb: verb, line: line, trimmed: trimmed)62        }63        if verbTable.fileVerbs.contains(verb) {64            return try parseFileCommand(verb: verb, trimmed: trimmed)65        }66        return try parseStandardCommand(verb: verb, line: line, trimmed: trimmed)67    }6869    // MARK: - Prefix commands7071    private func parsePrefixCommand(72        verb: String, line: String, trimmed: String73    ) throws(ZQParseError) -> ZQCommand {74        // Split on the first top-level colon: `bootstrap, reps(1000): reg y x`75        guard let colonIndex = topLevelColonIndex(in: trimmed) else {76            throw ZQParseError(77                message: "prefix command '\(verb)' requires ': command'",78                column: trimmed.count + 179            )80        }81        let head = String(trimmed[trimmed.startIndex..<colonIndex])82        let tail = String(trimmed[trimmed.index(after: colonIndex)...])83            .trimmingCharacters(in: .whitespaces)8485        var command = try parseStandardCommand(verb: verb, line: head, trimmed: head)86        guard let body = try parse(tail) else {87            throw ZQParseError(88                message: "prefix command '\(verb)' has an empty body",89                column: trimmed.count + 190            )91        }92        command.body = body93        return command94    }9596    /// Index of the first colon that is not nested inside parentheses,97    /// brackets, or a string literal.98    private func topLevelColonIndex(in text: String) -> String.Index? {99        var depth = 0100        var inString = false101        var index = text.startIndex102        while index < text.endIndex {103            let c = text[index]104            if c == "\"" { inString.toggle() }105            if !inString {106                switch c {107                case "(", "[": depth += 1108                case ")", "]": depth -= 1109                case ":" where depth == 0: return index110                default: break111                }112            }113            index = text.index(after: index)114        }115        return nil116    }117118    // MARK: - File commands119120    private func parseFileCommand(121        verb: String, trimmed: String122    ) throws(ZQParseError) -> ZQCommand {123        // Everything after the verb and before the first top-level comma is124        // a raw path (quoted paths keep spaces); options follow the comma.125        let afterVerb = trimmed.drop { !$0.isWhitespace }126        var path = String(afterVerb).trimmingCharacters(in: .whitespaces)127        var options: [ZQOption] = []128129        if let commaIndex = topLevelCommaIndex(in: path) {130            let optionText = String(path[path.index(after: commaIndex)...])131            path = String(path[path.startIndex..<commaIndex])132                .trimmingCharacters(in: .whitespaces)133            let lexer = ZQLexer()134            let tokens = try lexer.tokenize(optionText)135            var cursor = ExpressionParser(tokens: tokens)136            options = try parseOptions(&cursor)137        }138139        // `use file` / `import file` accept an optional leading `using`.140        for keyword in ["using ", "using\t"] where path.hasPrefix(keyword) {141            path = String(path.dropFirst(keyword.count))142                .trimmingCharacters(in: .whitespaces)143        }144        if path.hasPrefix("\""), path.hasSuffix("\""), path.count >= 2 {145            path = String(path.dropFirst().dropLast())146        }147148        return ZQCommand(149            verb: verb,150            argument: path.isEmpty ? nil : path,151            options: options152        )153    }154155    private func topLevelCommaIndex(in text: String) -> String.Index? {156        var depth = 0157        var inString = false158        var index = text.startIndex159        while index < text.endIndex {160            let c = text[index]161            if c == "\"" { inString.toggle() }162            if !inString {163                switch c {164                case "(", "[": depth += 1165                case ")", "]": depth -= 1166                case "," where depth == 0: return index167                default: break168                }169            }170            index = text.index(after: index)171        }172        return nil173    }174175    // MARK: - Standard commands176177    private func parseStandardCommand(178        verb: String, line: String, trimmed: String179    ) throws(ZQParseError) -> ZQCommand {180        let lexer = ZQLexer()181        let tokens = try lexer.tokenize(trimmed)182        var cursor = ExpressionParser(tokens: tokens)183184        // Consume the (already resolved) verb token.185        _ = cursor.advance()186        var command = ZQCommand(verb: verb)187188        // Compound verbs: `graph scatter y x`, `ivregress 2sls …`. A189        // digit-led sub-command like `2sls` lexes as number+identifier;190        // reassemble when the tokens are column-adjacent.191        if verbTable.compoundVerbs.contains(verb) {192            switch cursor.current.kind {193            case .identifier(let sub):194                _ = cursor.advance()195                command.subverb = sub196            case .number(let value) where value == value.rounded() && value >= 0:197                let numberToken = cursor.advance()198                let prefix = String(Int(value))199                guard case .identifier(let rest) = cursor.current.kind,200                      cursor.current.column == numberToken.column + prefix.count else {201                    throw ZQParseError(202                        message: "'\(verb)' requires a sub-command",203                        column: numberToken.column204                    )205                }206                _ = cursor.advance()207                command.subverb = prefix + rest208            default:209                throw ZQParseError(210                    message: "'\(verb)' requires a sub-command",211                    column: cursor.current.column212                )213            }214        }215216        // `set seed 42` → subverb "seed", argument "42"217        if verb == "set" {218            guard case .identifier(let sub) = cursor.current.kind else {219                throw ZQParseError(220                    message: "'set' requires a parameter name",221                    column: cursor.current.column222                )223            }224            _ = cursor.advance()225            command.subverb = sub226            if case .number(let value) = cursor.current.kind {227                _ = cursor.advance()228                command.argument = renderNumber(value)229            } else if case .identifier(let word) = cursor.current.kind {230                _ = cursor.advance()231                command.argument = word232            }233            try expectEnd(&cursor)234            return command235        }236237        // `display 2 + 2` — a bare expression command.238        if verb == "display" {239            let expression = try cursor.parseExpression()240            command.assignment = ZQAssignment(target: "", expression: expression)241            try expectEnd(&cursor)242            return command243        }244245        // Assignment verbs: `gen log_rev = ln(revenue)`246        if verbTable.assignmentVerbs.contains(verb) {247            guard case .identifier(let target) = cursor.current.kind else {248                throw ZQParseError(249                    message: "'\(verb)' requires a variable name",250                    column: cursor.current.column251                )252            }253            _ = cursor.advance()254            try cursor.expect(.op("="))255            let expression = try cursor.parseExpression()256            command.assignment = ZQAssignment(target: target, expression: expression)257        } else {258            let (varlist, ivSpec) = try parseVarlist(&cursor)259            command.varlist = varlist260            command.ivSpec = ivSpec261        }262263        // Qualifiers may appear in any sensible order; Stata fixes the264        // order as if → in → weight, which we enforce implicitly.265        if consumeKeyword(&cursor, "if") {266            command.condition = try cursor.parseExpression()267        }268        if consumeKeyword(&cursor, "in") {269            command.range = try parseRange(&cursor)270        }271        if cursor.isKind(.lbracket) {272            command.weight = try parseWeight(&cursor)273        }274        if cursor.consumeIfKind(.comma) {275            command.options = try parseOptions(&cursor)276        }277        try expectEnd(&cursor)278        return command279    }280281    // MARK: - Varlist282283    private func parseVarlist(284        _ cursor: inout ExpressionParser285    ) throws(ZQParseError) -> ([ZQVarSpec], ZQIVSpec?) {286        var specs: [ZQVarSpec] = []287        var ivSpec: ZQIVSpec?288        loop: while true {289            switch cursor.current.kind {290            case .identifier(let name):291                if name == "if" || name == "in" { break loop }292                specs.append(try parseVarSpec(&cursor))293            case .lparen:294                // IV group: (endog… = instruments…)295                guard ivSpec == nil else {296                    throw ZQParseError(297                        message: "only one (endogenous = instruments) group is allowed",298                        column: cursor.current.column299                    )300                }301                ivSpec = try parseIVGroup(&cursor)302            default:303                break loop304            }305        }306        return (specs, ivSpec)307    }308309    private func parseIVGroup(310        _ cursor: inout ExpressionParser311    ) throws(ZQParseError) -> ZQIVSpec {312        try cursor.expect(.lparen)313        var endogenous: [String] = []314        while case .identifier(let name) = cursor.current.kind {315            endogenous.append(name)316            _ = cursor.advance()317        }318        guard !endogenous.isEmpty else {319            throw ZQParseError(320                message: "expected endogenous variable names before '='",321                column: cursor.current.column322            )323        }324        try cursor.expect(.op("="))325        var instruments: [String] = []326        while case .identifier(let name) = cursor.current.kind {327            instruments.append(name)328            _ = cursor.advance()329        }330        guard !instruments.isEmpty else {331            throw ZQParseError(332                message: "expected instrument names after '='",333                column: cursor.current.column334            )335        }336        try cursor.expect(.rparen)337        return ZQIVSpec(endogenous: endogenous, instruments: instruments)338    }339340    private func parseVarSpec(341        _ cursor: inout ExpressionParser342    ) throws(ZQParseError) -> ZQVarSpec {343        var terms: [ZQVarSpec] = [try parseFactorTerm(&cursor)]344        var full = false345        while case .op(let symbol) = cursor.current.kind, symbol == "#" || symbol == "##" {346            full = full || symbol == "##"347            _ = cursor.advance()348            terms.append(try parseFactorTerm(&cursor))349        }350        return terms.count == 1 ? terms[0] : .interaction(terms, full: full)351    }352353    private func parseFactorTerm(354        _ cursor: inout ExpressionParser355    ) throws(ZQParseError) -> ZQVarSpec {356        guard case .identifier(let first) = cursor.current.kind else {357            throw ZQParseError(358                message: "expected a variable name, found \(cursor.current.kind.describe)",359                column: cursor.current.column360            )361        }362        _ = cursor.advance()363        if cursor.isKind(.op(".")) {364            _ = cursor.advance()365            guard case .identifier(let name) = cursor.current.kind else {366                throw ZQParseError(367                    message: "expected a variable name after '\(first).'",368                    column: cursor.current.column369                )370            }371            _ = cursor.advance()372            return .factor(op: first, name: name)373        }374        return .simple(first)375    }376377    // MARK: - Qualifiers378379    private func consumeKeyword(_ cursor: inout ExpressionParser, _ keyword: String) -> Bool {380        if case .identifier(let word) = cursor.current.kind, word == keyword {381            _ = cursor.advance()382            return true383        }384        return false385    }386387    private func parseRange(388        _ cursor: inout ExpressionParser389    ) throws(ZQParseError) -> ZQRange {390        let lower = try parseRangeBound(&cursor)391        try cursor.expect(.op("/"))392        let upper = try parseRangeBound(&cursor)393        return ZQRange(lower: lower, upper: upper)394    }395396    private func parseRangeBound(397        _ cursor: inout ExpressionParser398    ) throws(ZQParseError) -> Int {399        var sign = 1400        if cursor.consumeIfKind(.op("-")) { sign = -1 }401        // `f` and `l` mean first and last observation.402        if case .identifier(let word) = cursor.current.kind {403            if word == "f" { _ = cursor.advance(); return 1 }404            if word == "l" { _ = cursor.advance(); return -1 }405        }406        guard case .number(let value) = cursor.current.kind,407              value == value.rounded() else {408            throw ZQParseError(409                message: "expected an integer observation number",410                column: cursor.current.column411            )412        }413        _ = cursor.advance()414        return sign * Int(value)415    }416417    private func parseWeight(418        _ cursor: inout ExpressionParser419    ) throws(ZQParseError) -> ZQWeight {420        try cursor.expect(.lbracket)421        guard case .identifier(let kindWord) = cursor.current.kind else {422            throw ZQParseError(423                message: "expected a weight type (aweight, fweight, pweight, iweight)",424                column: cursor.current.column425            )426        }427        let normalized: ZQWeight.Kind? = switch kindWord {428        case "aweight", "aw", "w", "weight": .aweight429        case "fweight", "fw": .fweight430        case "pweight", "pw": .pweight431        case "iweight", "iw": .iweight432        default: nil433        }434        guard let kind = normalized else {435            throw ZQParseError(436                message: "unknown weight type '\(kindWord)'",437                column: cursor.current.column438            )439        }440        _ = cursor.advance()441        try cursor.expect(.op("="))442        let expression = try cursor.parseExpression()443        try cursor.expect(.rbracket)444        return ZQWeight(kind: kind, expression: expression)445    }446447    // MARK: - Options448449    private func parseOptions(450        _ cursor: inout ExpressionParser451    ) throws(ZQParseError) -> [ZQOption] {452        var options: [ZQOption] = []453        while case .identifier(let name) = cursor.current.kind {454            _ = cursor.advance()455            var arguments: [String] = []456            if cursor.consumeIfKind(.lparen) {457                var depth = 1458                var pieces: [String] = []459                while depth > 0 {460                    let token = cursor.current461                    switch token.kind {462                    case .lparen:463                        depth += 1464                        pieces.append("(")465                    case .rparen:466                        depth -= 1467                        if depth > 0 { pieces.append(")") }468                    case .endOfLine:469                        throw ZQParseError(470                            message: "unclosed '(' in option '\(name)'",471                            column: token.column472                        )473                    case .identifier(let word):474                        pieces.append(word)475                    case .number(let value):476                        pieces.append(renderNumber(value))477                    case .string(let text):478                        pieces.append("\"\(text)\"")479                    case .comma:480                        pieces.append(",")481                    case .colon:482                        pieces.append(":")483                    case .lbracket:484                        pieces.append("[")485                    case .rbracket:486                        pieces.append("]")487                    case .op(let symbol):488                        pieces.append(symbol)489                    }490                    _ = cursor.advance()491                }492                // Arguments split on top-level spaces in the original text493                // are reassembled here token-by-token: identifiers and494                // numbers stand alone, operator glue joins with neighbors.495                arguments = assembleOptionArguments(pieces)496            }497            options.append(ZQOption(name: name, arguments: arguments))498            // Optional commas between options are tolerated.499            _ = cursor.consumeIfKind(.comma)500        }501        return options502    }503504    /// Joins raw option-argument pieces into logical arguments: glue505    /// operators (`.`, `=`, comparison, arithmetic) bind to their506    /// neighbors, and standalone words split on whitespace boundaries.507    private func assembleOptionArguments(_ pieces: [String]) -> [String] {508        let glue: Set<String> = [509            ".", "=", "==", "!=", "<", "<=", ">", ">=",510            "+", "-", "*", "/", "^", "#", "##", "&", "|", "!",511        ]512        var arguments: [String] = []513        var pending = ""514        var previousWasGlue = false515516        for piece in pieces {517            if piece == "," {518                if !pending.isEmpty { arguments.append(pending); pending = "" }519                previousWasGlue = false520                continue521            }522            if glue.contains(piece) {523                pending += piece524                previousWasGlue = true525            } else if previousWasGlue || pending.isEmpty {526                pending += piece527                previousWasGlue = false528            } else {529                arguments.append(pending)530                pending = piece531                previousWasGlue = false532            }533        }534        if !pending.isEmpty { arguments.append(pending) }535        return arguments536    }537538    // MARK: - Helpers539540    private func expectEnd(_ cursor: inout ExpressionParser) throws(ZQParseError) {541        guard cursor.isKind(.endOfLine) else {542            throw ZQParseError(543                message: "unexpected \(cursor.current.kind.describe) after end of command",544                column: cursor.current.column545            )546        }547    }548549    private func renderNumber(_ value: Double) -> String {550        if value == value.rounded(), abs(value) < 1e15 {551            return String(Int64(value))552        }553        return String(value)554    }555556    private func columnOf(word: String, in line: String) -> Int {557        guard let range = line.range(of: word) else { return 1 }558        return line.distance(from: line.startIndex, to: range.lowerBound) + 1559    }560}561