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// 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