← 제니앱

시트

셀과 수식을 입력하면 의존성 순서로 계산하고, 순환 참조를 알리며, 표와 JSON으로 보여줍니다.

함수는 SUM, AVERAGE, MIN, MAX, COUNT, ABS, IF를 지원합니다. 빈 칸을 참조하면 0으로 봅니다. 같은 칸을 다시 적으면 마지막 값을 씁니다.

sheets.tpz
// Mini spreadsheet. Parses `<cell> <value>` lines, builds a cell dependency graph, evaluates
// formulas in topological order, detects circular references, renders the grid, and serializes
// the sheet to JSON. Written in Topaz. Deterministic and offline.
// Key identifiers: 셀=cell, 평가=evaluated result, 토큰=token, 줄결과=parse-with-position,
//   열=column, 행=row, 참조=ref like A1, 원본=raw text, 종류=kind, 수=number, 글=text,
//   오류=error, 토큰들=tokens, 위치=position, 결과들=results, 셀들=cells, 의존=dependency,
//   진입차수=in-degree, 인접=adjacency, 대기열=queue, 순서=topo order, 표시=display,
//   글자정리=escapeChar, 줄정리=escapeText, 숫자파싱=parseNumber, 참조파싱=parseRef.
//
// 미니 스프레드시트. `<셀> <값>` 줄을 읽어 셀 의존성 그래프를 만들고, 수식을 위상 순서로
// 계산하며, 순환 참조를 찾아 알리고, 표를 그리고, 시트를 JSON으로 직렬화합니다.
// A mini spreadsheet that evaluates formulas through a dependency graph in topological order.
// 줄마다 `A1 10`, `A3 =A1+A2`, `B1 =SUM(A1:A3)` 처럼 적습니다. `=`로 시작하면 수식입니다.
// One cell per line like `A1 10`, `A3 =A1+A2`, `B1 =SUM(A1:A3)`. A leading `=` marks a formula.
// 함수: SUM, AVERAGE, MIN, MAX, COUNT, ABS, IF. 비교: > < >= <= = <>.
// Functions: SUM, AVERAGE, MIN, MAX, COUNT, ABS, IF. Comparisons: > < >= <= = <>.
// 안 하는 것: 엑셀 호환, 셀 편집 UI, 차트, 문자열 함수, 매우 큰 표, 복사 시 참조 재작성, 반올림 함수.
// Won't do: Excel compatibility, cell-editing UI, charts, string functions, very large grids,
// reference rewriting on copy, a rounding function.
// 빈 셀 참조는 0으로 봅니다. 같은 셀을 두 번 적으면 마지막 정의가 이깁니다.
// An empty-cell reference reads as 0. A duplicate cell definition keeps the last one.

type 셀 = { 열: int, 행: int, 참조: string, 원본: string }
// 닫힌 태그들. 셀 이름과 함수 원문은 입력 경계라 string으로 두고, 내부 분기 태그만 좁힌다.
// Closed tags. Cell names and raw function text stay string at the input boundary; internal dispatch tags are narrowed.
type 평가종류 = "수" | "글" | "빈" | "오류"
type 토큰종류 = "수" | "셀" | "함수" | "비교" | "연산" | "괄호열" | "괄호닫" | "콤마" | "콜론"
type 비교연산 = ">" | "<" | ">=" | "<=" | "=" | "<>"
type 덧셈연산 = "+" | "-"
type 곱셈연산 = "*" | "/"
type 함수종류 = "SUM" | "COUNT" | "AVERAGE" | "MIN" | "MAX" | "ABS" | "IF"
type 평가 = { 종류: 평가종류, 수: float, 글: string, 오류: string }
type 토큰 = { 종류: 토큰종류, 글: string }
type 줄결과 = { 값: float, 위치: int, 오류: string }                // evaluator carries value, next position, error

// 레코드 리터럴은 문자열 리터럴을 넓히므로 태그 있는 레코드는 헬퍼에서 만든다.
// Record literals widen string literals, so tagged records are built through helpers.
function 토큰만들기(종류: 토큰종류, 글: string) -> 토큰 {
  { 종류: 종류, 글: 글 }
}

function 평가만들기(종류: 평가종류, 수: float, 글: string, 오류: string) -> 평가 {
  { 종류: 종류, 수: 수, 글: 글, 오류: 오류 }
}

function 빈평가() -> 평가 { 평가만들기("빈", 0.0, "", "") }
function 수평가(수: float) -> 평가 { 평가만들기("수", 수, "", "") }
function 글평가(글: string) -> 평가 { 평가만들기("글", 0.0, 글, "") }
function 오류평가(오류: string) -> 평가 { 평가만들기("오류", 0.0, "", 오류) }

function 글자정리(글: string) -> string {
  if 글 == "&" { "&amp;" }
  else if 글 == "<" { "&lt;" }
  else if 글 == ">" { "&gt;" }
  else if 글 == "\"" { "&quot;" }
  else { 글 }
}

function 줄정리(원문: string) -> string {
  let mut 출력 = ""
  for 글 in 원문.scalars() { 출력 = 출력 + 글자정리(글) }
  출력
}

function 숫자인가(글: string) -> bool {
  let 코 = 글.codePointAt(0) ?? 0
  코 >= 48 && 코 <= 57
}

function 영문인가(글: string) -> bool {
  let 코 = 글.codePointAt(0) ?? 0
  (코 >= 65 && 코 <= 90) || (코 >= 97 && 코 <= 122)
}

function 대문자글(글: string) -> string {
  let 코 = 글.codePointAt(0) ?? 0
  if 코 >= 97 && 코 <= 122 { fromCodePoint(코 - 32) ?? 글 } else { 글 }
}

function 비교연산파싱(글: string) -> Result<비교연산, string> {
  match 글 {
    case ">" => Ok(">")
    case "<" => Ok("<")
    case ">=" => Ok(">=")
    case "<=" => Ok("<=")
    case "=" => Ok("=")
    case "<>" => Ok("<>")
    case _ => Err("알 수 없는 비교 연산자: " + 글)
  }
}

function 비교참(연: 비교연산, 왼: float, 오: float) -> bool {
  match 연 {
    case ">" => 왼 > 오
    case "<" => 왼 < 오
    case ">=" => 왼 >= 오
    case "<=" => 왼 <= 오
    case "=" => 왼 == 오
    case "<>" => 왼 != 오
  }
}

function 덧셈연산파싱(글: string) -> Result<덧셈연산, string> {
  match 글 {
    case "+" => Ok("+")
    case "-" => Ok("-")
    case _ => Err("덧셈 연산자가 필요합니다")
  }
}

function 덧셈계산(연: 덧셈연산, 왼: float, 오: float) -> float {
  match 연 {
    case "+" => 왼 + 오
    case "-" => 왼 - 오
  }
}

function 곱셈연산파싱(글: string) -> Result<곱셈연산, string> {
  match 글 {
    case "*" => Ok("*")
    case "/" => Ok("/")
    case _ => Err("곱셈 연산자가 필요합니다")
  }
}

function 곱셈계산(연: 곱셈연산, 왼: float, 오: float) -> float {
  match 연 {
    case "*" => 왼 * 오
    case "/" => 왼 / 오
  }
}

function 함수종류파싱(이름: string) -> Result<함수종류, string> {
  match 이름 {
    case "SUM" => Ok("SUM")
    case "COUNT" => Ok("COUNT")
    case "AVERAGE" => Ok("AVERAGE")
    case "MIN" => Ok("MIN")
    case "MAX" => Ok("MAX")
    case "ABS" => Ok("ABS")
    case "IF" => Ok("IF")
    case _ => Err("알 수 없는 함수: " + 이름)
  }
}

function 집계함수인가(함수: 함수종류) -> bool {
  match 함수 {
    case "SUM" => true
    case "COUNT" => true
    case "AVERAGE" => true
    case "MIN" => true
    case "MAX" => true
    case "ABS" => false
    case "IF" => false
  }
}

// 정수 거듭제곱(10^n)을 float로. round/floor 없이 자릿수 나눗셈에 씁니다.
// 10^n as a float, used for decimal place division without a round/floor builtin.
function 십승(엔: int) -> float {
  let mut 값 = 1.0
  let mut 회 = 0
  while 회 < 엔 { 값 = 값 * 10.0; 회 = 회 + 1 }
  값
}

// 문자열을 float로 파싱합니다. 정수, 소수, 앞의 음수 부호를 허용합니다. 실패하면 오류.
// Parse a string to a float. Allows an integer, a decimal, and a leading minus. Errors on failure.
function 숫자파싱(원: string) -> 줄결과 {
  let 글자들 = 원.scalars()
  if 글자들.length == 0 { return { 값: 0.0, 위치: 0, 오류: "빈 수" } }
  let mut 부호 = 1.0
  let mut 시 = 0
  if 글자들[0] == "-" { 부호 = -1.0; 시 = 1 }
  else if 글자들[0] == "+" { 시 = 1 }
  let mut 정수부 = ""
  let mut 소수부 = ""
  let mut 점봤나 = false
  let mut 자리 = 시
  let mut 글자있나 = false
  while 자리 < 글자들.length {
    let 글 = 글자들[자리]
    if 글 == "." {
      if 점봤나 { return { 값: 0.0, 위치: 0, 오류: "잘못된 수: {원}" } }
      점봤나 = true
    } else if 숫자인가(글) {
      글자있나 = true
      if 점봤나 { 소수부 = 소수부 + 글 } else { 정수부 = 정수부 + 글 }
    } else {
      return { 값: 0.0, 위치: 0, 오류: "잘못된 수: {원}" }
    }
    자리 = 자리 + 1
  }
  if !글자있나 { return { 값: 0.0, 위치: 0, 오류: "잘못된 수: {원}" } }
  let 정수값 = if 정수부 == "" { 0 } else { toInt(정수부) ?? 0 }
  let mut 값 = toFloat(정수값)
  if 소수부 != "" {
    let 소수값 = toInt(소수부) ?? 0
    값 = 값 + toFloat(소수값) / 십승(소수부.scalars().length)
  }
  { 값: 부호 * 값, 위치: 0, 오류: "" }
}

// float를 표시용 문자열로. 정수면 정수로, 아니면 소수 최대 4자리까지(버림) 보입니다.
// Format a float for display: integers as integers, otherwise up to 4 decimal places (truncated).
function 표시(엑스: float) -> string {
  let 원 = "{엑스}"
  let 조각 = 원.split(".")
  if 조각.length < 2 { return 원 }
  let 정수 = 조각[0]
  let 소수 = 조각[1]
  // 모두 0이면 정수만.
  // If the fraction is all zeros, show the integer only.
  let mut 영뿐 = true
  for 글 in 소수.scalars() { if 글 != "0" { 영뿐 = false } }
  if 영뿐 { return 정수 }
  let mut 자른소수 = ""
  let mut 개수 = 0
  for 글 in 소수.scalars() {
    if 개수 < 4 { 자른소수 = 자른소수 + 글; 개수 = 개수 + 1 }
  }
  // 끝자리 0 제거.
  // Strip trailing zeros.
  let 자글 = 자른소수.scalars()
  let mut 끝 = 자글.length
  while 끝 > 0 && 자글[끝 - 1] == "0" { 끝 = 끝 - 1 }
  if 끝 == 0 { return 정수 }
  let mut 마무리 = ""
  let mut 부 = 0
  while 부 < 끝 { 마무리 = 마무리 + 자글[부]; 부 = 부 + 1 }
  정수 + "." + 마무리
}

// 열 색인을 문자로(0->A, 25->Z, 26->AA). 표 머리글에 씁니다.
// Column index to letters (0->A, 25->Z, 26->AA). Used for table headers.
function 열문자(열: int) -> string {
  let mut 값 = 열
  let mut 결과 = ""
  let mut 계속 = true
  while 계속 {
    let 나머지 = 값 - (값 / 26) * 26
    결과 = (fromCodePoint(65 + 나머지) ?? "A") + 결과
    값 = 값 / 26 - 1
    if 값 < 0 { 계속 = false }
  }
  결과
}

// "A1" 같은 참조를 (열, 행)으로. 문자 뒤 숫자라야 합니다. 행은 1부터.
// Parse a ref like "A1" into (column, row). Letters then digits. Row is 1-based.
function 참조파싱(참조: string) -> { 열: int, 행: int, 오류: string } {
  let 글자들 = 참조.scalars()
  if 글자들.length == 0 { return { 열: 0, 행: 0, 오류: "빈 참조" } }
  let mut 자리 = 0
  let mut 열 = 0
  let mut 문자수 = 0
  while 자리 < 글자들.length && 영문인가(글자들[자리]) {
    let 대 = 대문자글(글자들[자리])
    let 코 = 대.codePointAt(0) ?? 0
    열 = 열 * 26 + (코 - 65 + 1)
    문자수 = 문자수 + 1
    자리 = 자리 + 1
  }
  if 문자수 == 0 { return { 열: 0, 행: 0, 오류: "잘못된 참조: {참조}" } }
  let mut 숫자수 = 0
  let mut 행 = 0
  while 자리 < 글자들.length && 숫자인가(글자들[자리]) {
    행 = 행 * 10 + (글자들[자리].codePointAt(0) ?? 48) - 48
    숫자수 = 숫자수 + 1
    자리 = 자리 + 1
  }
  if 숫자수 == 0 || 자리 != 글자들.length || 행 == 0 {
    return { 열: 0, 행: 0, 오류: "잘못된 참조: {참조}" }
  }
  { 열: 열 - 1, 행: 행, 오류: "" }
}

// 참조를 표준형으로. "a01" "A01" "A1"은 모두 같은 칸 "A1"이 됩니다(열 대문자 + 정수 행).
// 파싱 실패면 대문자만 적용해 돌려줍니다(검증은 호출부에서).
// Canonicalize a ref: "a01", "A01", "A1" all become the same cell "A1" (uppercase column + integer row).
// If it does not parse, just uppercase it (the caller validates).
function 참조정규화(참조: string) -> string {
  let 검 = 참조파싱(참조)
  if 검.오류 != "" {
    let mut 출 = ""
    for 글 in 참조.scalars() { 출 = 출 + 대문자글(글) }
    return 출
  }
  열문자(검.열) + "{검.행}"
}

// 앞 엔 글자를 뺀 나머지 문자열. s.slice는 스칼라 인덱스를 받는다.
// The substring after dropping the first 엔 characters. s.slice is scalar-indexed.
function 꼬리(원: string, 엔: int) -> string {
  let 글자들 = 원.scalars()
  글자들.slice(엔, 글자들.length).join("")
}

function 셀찾기(참조: string, 셀들: Array<셀>) -> int {
  let mut 자리 = 0
  while 자리 < 셀들.length {
    if 셀들[자리].참조 == 참조 { return 자리 }
    자리 = 자리 + 1
  }
  -1
}

// 수식을 토큰으로 나눕니다. 수, 셀참조, 함수이름, 연산자, 비교, 괄호, 콤마, 콜론.
// Tokenize a formula: numbers, cell refs, function names, operators, comparisons, parens, comma, colon.
function 토큰화(식: string) -> { 토큰들: Array<토큰>, 오류: string } {
  let 글자들 = 식.scalars()
  let mut 토큰들: Array<토큰> = []
  let mut 자리 = 0
  while 자리 < 글자들.length {
    let 글 = 글자들[자리]
    if 글 == " " || 글 == "\t" {
      자리 = 자리 + 1
    } else if 숫자인가(글) || 글 == "." {
      let mut 수글 = ""
      while 자리 < 글자들.length && (숫자인가(글자들[자리]) || 글자들[자리] == ".") {
        수글 = 수글 + 글자들[자리]
        자리 = 자리 + 1
      }
      토큰들.push(토큰만들기("수", 수글))
    } else if 영문인가(글) {
      let mut 단어 = ""
      while 자리 < 글자들.length && 영문인가(글자들[자리]) {
        단어 = 단어 + 대문자글(글자들[자리])
        자리 = 자리 + 1
      }
      let mut 숫자 = ""
      while 자리 < 글자들.length && 숫자인가(글자들[자리]) {
        숫자 = 숫자 + 글자들[자리]
        자리 = 자리 + 1
      }
      if 숫자 != "" {
        토큰들.push(토큰만들기("셀", 단어 + 숫자))
      } else {
        토큰들.push(토큰만들기("함수", 단어))
      }
    } else if 글 == ">" || 글 == "<" {
      자리 = 자리 + 1
      if 자리 < 글자들.length && 글자들[자리] == "=" {
        토큰들.push(토큰만들기("비교", 글 + "="))
        자리 = 자리 + 1
      } else if 글 == "<" && 자리 < 글자들.length && 글자들[자리] == ">" {
        토큰들.push(토큰만들기("비교", "<>"))
        자리 = 자리 + 1
      } else {
        토큰들.push(토큰만들기("비교", 글))
      }
    } else if 글 == "=" {
      토큰들.push(토큰만들기("비교", "="))
      자리 = 자리 + 1
    } else if 글 == "+" || 글 == "-" || 글 == "*" || 글 == "/" {
      토큰들.push(토큰만들기("연산", 글))
      자리 = 자리 + 1
    } else if 글 == "(" {
      토큰들.push(토큰만들기("괄호열", "("))
      자리 = 자리 + 1
    } else if 글 == ")" {
      토큰들.push(토큰만들기("괄호닫", ")"))
      자리 = 자리 + 1
    } else if 글 == "," {
      토큰들.push(토큰만들기("콤마", ","))
      자리 = 자리 + 1
    } else if 글 == ":" {
      토큰들.push(토큰만들기("콜론", ":"))
      자리 = 자리 + 1
    } else {
      return { 토큰들: 토큰들, 오류: "알 수 없는 문자: {글}" }
    }
  }
  { 토큰들: 토큰들, 오류: "" }
}

// 범위 A1:A3을 셀 참조 목록으로 펼칩니다(사각형).
// Expand a range A1:A3 into a list of cell refs (rectangular).
function 범위펼치기(시작참조: string, 끝참조: string) -> { 참조들: Array<string>, 오류: string } {
  let 가 = 참조파싱(시작참조)
  let 나 = 참조파싱(끝참조)
  let mut 참조들: Array<string> = []
  if 가.오류 != "" { return { 참조들: 참조들, 오류: 가.오류 } }
  if 나.오류 != "" { return { 참조들: 참조들, 오류: 나.오류 } }
  // 끝점이 뒤집힌 범위(B2:A1)는 표준 시트처럼 정규화한다(A1:B2). 의도된 동작이라 거부하지 않는다.
  // A reversed range (B2:A1) is normalized like a standard spreadsheet (A1:B2). Intentional, not rejected.
  let mut 열로 = if 가.열 < 나.열 { 가.열 } else { 나.열 }
  let 열까지 = if 가.열 < 나.열 { 나.열 } else { 가.열 }
  let mut 행시작 = if 가.행 < 나.행 { 가.행 } else { 나.행 }
  let 행끝 = if 가.행 < 나.행 { 나.행 } else { 가.행 }
  while 열로 <= 열까지 {
    let mut 행 = 행시작
    while 행 <= 행끝 {
      참조들.push(열문자(열로) + "{행}")
      행 = 행 + 1
    }
    열로 = 열로 + 1
  }
  { 참조들: 참조들, 오류: "" }
}

// 수식 토큰에서 참조하는 셀들을 모읍니다(범위는 펼침). 의존성 그래프용.
// Collect referenced cells from formula tokens (ranges expanded). For the dependency graph.
function 참조수집(토큰들: Array<토큰>) -> Array<string> {
  let mut 모음: Array<string> = []
  let mut 자리 = 0
  while 자리 < 토큰들.length {
    if 토큰들[자리].종류 == "셀" {
      let 이참조 = 토큰들[자리].글
      if 자리 + 2 < 토큰들.length && 토큰들[자리 + 1].종류 == "콜론" && 토큰들[자리 + 2].종류 == "셀" {
        let 펼친 = 범위펼치기(이참조, 토큰들[자리 + 2].글)
        for 참 in 펼친.참조들 {
          if 모음.indexOf(참) == None { 모음.push(참) }
        }
        자리 = 자리 + 3
      } else {
        let 정규 = 참조정규화(이참조)
        if 모음.indexOf(정규) == None { 모음.push(정규) }
        자리 = 자리 + 1
      }
    } else {
      자리 = 자리 + 1
    }
  }
  모음
}

// 셀 한 칸의 수치값을 읽습니다. 빈 셀은 0, 텍스트나 오류 셀은 오류로 전파합니다.
// Read one cell's numeric value. Empty reads as 0; a text or errored cell propagates an error.
function 셀값(참조: string, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  // 직접 참조도 범위처럼 검증합니다. 잘못된 참조(예: 행이 0인 A0)는 0이 아니라 오류.
  // Validate a direct ref like ranges do; a bad ref (e.g. A0 with row 0) errors instead of 0.
  let 검 = 참조파싱(참조)
  if 검.오류 != "" { return { 값: 0.0, 위치: 0, 오류: 검.오류 } }
  // 구조 검증 모드: 유효한 참조는 값과 무관하게 더미 1로 봅니다.
  // Structural mode: a valid ref resolves to a dummy 1, independent of cell values.
  if 구조 { return { 값: 1.0, 위치: 0, 오류: "" } }
  let 색인 = 셀찾기(참조정규화(참조), 셀들)
  if 색인 < 0 { return { 값: 0.0, 위치: 0, 오류: "" } }
  let 결 = 결과들[색인]
  match 결.종류 {
    case "오류" => { 값: 0.0, 위치: 0, 오류: "참조 셀 오류 {참조}" }
    case "글" => { 값: 0.0, 위치: 0, 오류: "텍스트 셀 {참조}" }
    case "빈" => { 값: 0.0, 위치: 0, 오류: "" }
    case "수" => { 값: 결.수, 위치: 0, 오류: "" }
  }
}

// 함수 인자로 들어온 범위/셀들의 수치 목록을 모읍니다. 빈 칸은 건너뜁니다.
// Gather the numeric values of a function argument that is a range or single cell. Empty cells skipped.
function 범위값들(시작: string, 끝: string, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> { 값들: Array<float>, 오류: string } {
  let 펼친 = 범위펼치기(시작, 끝)
  let mut 값들: Array<float> = []
  if 펼친.오류 != "" { return { 값들: 값들, 오류: 펼친.오류 } }
  // 구조 검증 모드: 범위가 유효하면 더미 1 한 개로 빈 집계 오류를 피합니다.
  // Structural mode: a valid range yields one dummy so an empty-aggregate value error cannot fire.
  if 구조 { 값들.push(1.0); return { 값들: 값들, 오류: "" } }
  for 참 in 펼친.참조들 {
    let 색인 = 셀찾기(참, 셀들)
    if 색인 >= 0 {
      let 결 = 결과들[색인]
      match 결.종류 {
        case "오류" => return { 값들: 값들, 오류: "참조 셀 오류 {참}" }
        case "수" => { 값들.push(결.수) }
        case "글" => {}
        case "빈" => {}
      }
    }
  }
  { 값들: 값들, 오류: "" }
}

// 재귀하강 평가기. 비교 < 덧셈 < 곱셈 < 단항 < 기본 순으로 우선순위가 올라갑니다.
// 각 함수는 {값, 다음 위치, 오류}를 돌려줍니다(오류가 있으면 위로 전파).
// Recursive-descent evaluator. Precedence rises comparison < add < mul < unary < primary.
// Each function returns {value, next position, error}; an error propagates upward.
function 비교식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  let 왼 = 덧셈식(토큰들, 위치, 셀들, 결과들, 구조)
  if 왼.오류 != "" { return 왼 }
  let mut 위 = 왼.위치
  if 위 < 토큰들.length && 토큰들[위].종류 == "비교" {
    let 연 = match 비교연산파싱(토큰들[위].글) {
      case Ok(ㅇ) => ㅇ
      case Err(메) => return { 값: 0.0, 위치: 위, 오류: 메 }
    }
    let 오 = 덧셈식(토큰들, 위 + 1, 셀들, 결과들, 구조)
    if 오.오류 != "" { return 오 }
    let 참 = 비교참(연, 왼.값, 오.값)
    let 값 = if 참 { 1.0 } else { 0.0 }
    return { 값: 값, 위치: 오.위치, 오류: "" }
  }
  왼
}

function 덧셈식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  let mut 누적 = 곱셈식(토큰들, 위치, 셀들, 결과들, 구조)
  if 누적.오류 != "" { return 누적 }
  let mut 위 = 누적.위치
  while 위 < 토큰들.length && 토큰들[위].종류 == "연산" && (토큰들[위].글 == "+" || 토큰들[위].글 == "-") {
    let 연 = match 덧셈연산파싱(토큰들[위].글) {
      case Ok(ㅇ) => ㅇ
      case Err(메) => return { 값: 0.0, 위치: 위, 오류: 메 }
    }
    let 오 = 곱셈식(토큰들, 위 + 1, 셀들, 결과들, 구조)
    if 오.오류 != "" { return 오 }
    let 값 = 덧셈계산(연, 누적.값, 오.값)
    누적 = { 값: 값, 위치: 오.위치, 오류: "" }
    위 = 오.위치
  }
  누적
}

function 곱셈식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  let mut 누적 = 단항식(토큰들, 위치, 셀들, 결과들, 구조)
  if 누적.오류 != "" { return 누적 }
  let mut 위 = 누적.위치
  while 위 < 토큰들.length && 토큰들[위].종류 == "연산" && (토큰들[위].글 == "*" || 토큰들[위].글 == "/") {
    let 연 = match 곱셈연산파싱(토큰들[위].글) {
      case Ok(ㅇ) => ㅇ
      case Err(메) => return { 값: 0.0, 위치: 위, 오류: 메 }
    }
    let 오 = 단항식(토큰들, 위 + 1, 셀들, 결과들, 구조)
    if 오.오류 != "" { return 오 }
    if !구조 && 연 == "/" && 오.값 == 0.0 { return { 값: 0.0, 위치: 오.위치, 오류: "0으로 나눌 수 없습니다" } }
    let 값 = if 구조 { 1.0 } else { 곱셈계산(연, 누적.값, 오.값) }
    누적 = { 값: 값, 위치: 오.위치, 오류: "" }
    위 = 오.위치
  }
  누적
}

function 단항식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  if 위치 < 토큰들.length && 토큰들[위치].종류 == "연산" && 토큰들[위치].글 == "-" {
    let 안 = 단항식(토큰들, 위치 + 1, 셀들, 결과들, 구조)
    if 안.오류 != "" { return 안 }
    return { 값: 0.0 - 안.값, 위치: 안.위치, 오류: "" }
  }
  if 위치 < 토큰들.length && 토큰들[위치].종류 == "연산" && 토큰들[위치].글 == "+" {
    return 단항식(토큰들, 위치 + 1, 셀들, 결과들, 구조)
  }
  기본식(토큰들, 위치, 셀들, 결과들, 구조)
}

function 기본식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  if 위치 >= 토큰들.length { return { 값: 0.0, 위치: 위치, 오류: "수식이 끝났습니다" } }
  let 토 = 토큰들[위치]
  match 토.종류 {
    case "수" => {
      let 파 = 숫자파싱(토.글)
      if 파.오류 != "" { return { 값: 0.0, 위치: 위치, 오류: 파.오류 } }
      return { 값: 파.값, 위치: 위치 + 1, 오류: "" }
    }
    case "괄호열" => {
      let 안 = 비교식(토큰들, 위치 + 1, 셀들, 결과들, 구조)
      if 안.오류 != "" { return 안 }
      if 안.위치 >= 토큰들.length || 토큰들[안.위치].종류 != "괄호닫" {
        return { 값: 0.0, 위치: 안.위치, 오류: "괄호가 닫히지 않았습니다" }
      }
      return { 값: 안.값, 위치: 안.위치 + 1, 오류: "" }
    }
    case "셀" => {
      if 위치 + 1 < 토큰들.length && 토큰들[위치 + 1].종류 == "콜론" {
        return { 값: 0.0, 위치: 위치, 오류: "범위는 함수 안에서만 쓸 수 있습니다" }
      }
      let 값 = 셀값(토.글, 셀들, 결과들, 구조)
      if 값.오류 != "" { return { 값: 0.0, 위치: 위치, 오류: 값.오류 } }
      return { 값: 값.값, 위치: 위치 + 1, 오류: "" }
    }
    case "함수" => return 함수평가(토.글, 토큰들, 위치 + 1, 셀들, 결과들, 구조)
    case "비교" => {}
    case "연산" => {}
    case "괄호닫" => {}
    case "콤마" => {}
    case "콜론" => {}
  }
  { 값: 0.0, 위치: 위치, 오류: "예상치 못한 토큰: {토.글}" }
}

function 함수평가(이름: string, 토큰들: Array<토큰>, 시작: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> 줄결과 {
  if 시작 >= 토큰들.length || 토큰들[시작].종류 != "괄호열" {
    return { 값: 0.0, 위치: 시작, 오류: "{이름} 뒤에 (가 필요합니다" }
  }
  let mut 위 = 시작 + 1
  let mut 값들: Array<float> = []
  let mut 인자수 = 0
  let mut 첫 = true
  let mut 계속 = true
  while 계속 {
    if 위 >= 토큰들.length { return { 값: 0.0, 위치: 위, 오류: "괄호가 닫히지 않았습니다" } }
    if 토큰들[위].종류 == "괄호닫" { 위 = 위 + 1; 계속 = false }
    else {
      if !첫 {
        if 토큰들[위].종류 == "콤마" { 위 = 위 + 1 }
        else { return { 값: 0.0, 위치: 위, 오류: "인자 사이에 콤마가 필요합니다" } }
      }
      첫 = false
      인자수 = 인자수 + 1
      if 위 + 2 < 토큰들.length && 토큰들[위].종류 == "셀" && 토큰들[위 + 1].종류 == "콜론" && 토큰들[위 + 2].종류 == "셀" {
        let 벌 = 범위값들(토큰들[위].글, 토큰들[위 + 2].글, 셀들, 결과들, 구조)
        if 벌.오류 != "" { return { 값: 0.0, 위치: 위, 오류: 벌.오류 } }
        for ㄱ in 벌.값들 { 값들.push(ㄱ) }
        위 = 위 + 3
      } else {
        let 부분 = 비교식(토큰들, 위, 셀들, 결과들, 구조)
        if 부분.오류 != "" { return 부분 }
        값들.push(부분.값)
        위 = 부분.위치
      }
    }
  }
  let 함수 = match 함수종류파싱(이름) {
    case Ok(ㅎ) => ㅎ
    case Err(메) => return { 값: 0.0, 위치: 위, 오류: 메 }
  }
  // 집계 함수는 인자가 최소 하나 필요하다(=SUM()는 오류). 빈 인자 목록을 구조 오류로 본다.
  // Aggregate functions need at least one argument (=SUM() errors); an empty arg list is a structural error.
  if 집계함수인가(함수) && 인자수 == 0 {
    return { 값: 0.0, 위치: 위, 오류: "{이름}은 인자가 필요합니다" }
  }
  match 함수 {
    case "SUM" => {
      let mut 합 = 0.0
      for ㄱ in 값들 { 합 = 합 + ㄱ }
      return { 값: 합, 위치: 위, 오류: "" }
    }
    case "COUNT" => return { 값: toFloat(값들.length), 위치: 위, 오류: "" }
    case "AVERAGE" => {
      if 값들.length == 0 { return { 값: 0.0, 위치: 위, 오류: "AVERAGE 대상이 없습니다" } }
      let mut 합 = 0.0
      for ㄱ in 값들 { 합 = 합 + ㄱ }
      return { 값: 합 / toFloat(값들.length), 위치: 위, 오류: "" }
    }
    case "MIN" => {
      if 값들.length == 0 { return { 값: 0.0, 위치: 위, 오류: "MIN 대상이 없습니다" } }
      let mut 작 = 값들[0]
      for ㄱ in 값들 { if ㄱ < 작 { 작 = ㄱ } }
      return { 값: 작, 위치: 위, 오류: "" }
    }
    case "MAX" => {
      if 값들.length == 0 { return { 값: 0.0, 위치: 위, 오류: "MAX 대상이 없습니다" } }
      let mut 큰 = 값들[0]
      for ㄱ in 값들 { if ㄱ > 큰 { 큰 = ㄱ } }
      return { 값: 큰, 위치: 위, 오류: "" }
    }
    case "ABS" => {
      if 값들.length != 1 { return { 값: 0.0, 위치: 위, 오류: "ABS는 인자 1개입니다" } }
      let ㄱ = 값들[0]
      return { 값: if ㄱ < 0.0 { 0.0 - ㄱ } else { ㄱ }, 위치: 위, 오류: "" }
    }
    case "IF" => {
      if 값들.length != 3 { return { 값: 0.0, 위치: 위, 오류: "IF는 인자 3개입니다" } }
      return { 값: if 값들[0] != 0.0 { 값들[1] } else { 값들[2] }, 위치: 위, 오류: "" }
    }
  }
}

function 순환소속(시작: int, 인접: Array<Array<int>>, 개수: int) -> bool {
  let mut 방문: Array<bool> = []
  let mut 초기 = 0
  while 초기 < 개수 { 방문.push(false); 초기 = 초기 + 1 }
  let mut 큐: Array<int> = []
  for 다음 in 인접[시작] { 큐.push(다음) }
  let mut 머 = 0
  while 머 < 큐.length {
    let 현 = 큐[머]
    머 = 머 + 1
    if 현 == 시작 { return true }
    if !방문[현] { 방문[현] = true; for 다음 in 인접[현] { 큐.push(다음) } }
  }
  false
}

function 제이슨문자(글: string) -> string {
  if 글 == "\"" { "\\\"" }
  else if 글 == "\\" { "\\\\" }
  else if 글 == "\n" { "\\n" }
  else if 글 == "\t" { "\\t" }
  else { 글 }
}

function 제이슨값(원: string) -> string {
  let mut 출 = ""
  for 글 in 원.scalars() { 출 = 출 + 제이슨문자(글) }
  출
}

function 보기(입력: string) -> string {
  let 줄들 = 입력.split("\n")
  let mut 셀들: Array<셀> = []
  let mut 줄번호 = 0
  for 줄 in 줄들 {
    줄번호 = 줄번호 + 1
    let 다듬 = 줄.trim()
    if 다듬 != "" {
      let 부분들 = 다듬.split(" ")
      if 부분들.length < 2 {
        return "<div class=\"err\">{줄번호}번째 줄: 형식은 `셀 값` 입니다. 예: A1 10</div>"
      }
      let 참조 = 참조정규화(부분들[0])
      let 값부 = 부분들.slice(1, 부분들.length).join(" ")
      let 파 = 참조파싱(참조)
      if 파.오류 != "" {
        return "<div class=\"err\">{줄번호}번째 줄: {파.오류}</div>"
      }
      let 기존 = 셀찾기(참조, 셀들)
      if 기존 >= 0 {
        셀들[기존] = 셀들[기존]{ 원본: 값부 }
      } else {
        셀들.push({ 열: 파.열, 행: 파.행, 참조: 참조, 원본: 값부 })
      }
    }
  }
  let 개수 = 셀들.length
  if 개수 == 0 {
    return "<div class=\"meta\">셀을 입력하세요. 예: A1 10 / A2 =A1*2</div>"
  }

  // 의존성 그래프. 간선 = 선행 셀 -> 의존 셀. 진입차수 = 그 셀이 참조하는 기존 셀 수.
  // Dependency graph. Edge = precedent -> dependent. In-degree = number of existing cells it references.
  let mut 진입차수: Array<int> = []
  let mut 인접: Array<Array<int>> = []
  let mut 준비 = 0
  while 준비 < 개수 {
    진입차수.push(0)
    let mut 빈: Array<int> = []
    인접.push(빈)
    준비 = 준비 + 1
  }
  // 구문 검사용 빈 스텁. 의존성 그래프를 만들기 전에 구문/참조 오류를 먼저 잡아,
  // 잘못된 수식(예: 괄호 안 닫힘)이 자기참조처럼 보여 가짜 순환으로 보고되지 않게 합니다.
  // An empty stub to catch syntax/ref errors before the dependency graph, so a malformed
  // formula (e.g. an unclosed paren) is not misreported as a self-cycle.
  let mut 스텁: Array<평가> = []
  let mut 스초기 = 0
  while 스초기 < 개수 { 스텁.push(빈평가()); 스초기 = 스초기 + 1 }
  let mut 자리 = 0
  while 자리 < 개수 {
    let 원본 = 셀들[자리].원본
    if 원본.scalars().length > 0 && 원본.scalars()[0] == "=" {
      let 식 = 꼬리(원본, 1)
      let 토 = 토큰화(식)
      // 그래프 포함 여부는 구문/참조 형태로만 판단한다(값 평가로 하지 않는다). 빈 스텁에서 값
      // 의존 오류가 나도 잘 형성된 수식이면 포함해 위상 평가가 실제 선행 값으로 계산하게 한다.
      // Decide graph inclusion by syntax/reference shape, not by value: a well-formed formula that
      // errors on the empty stub (value-dependent) must still be graphed and evaluated for real.
      let 구문올바름 = if 토.오류 != "" { false } else {
        let 구검 = 비교식(토.토큰들, 0, 셀들, 스텁, true)
        구검.오류 == "" && 구검.위치 == 토.토큰들.length
      }
      if 구문올바름 {
        let 참조들 = 참조수집(토.토큰들)
        for 참 in 참조들 {
          let 선행 = 셀찾기(참, 셀들)
          if 선행 >= 0 && 선행 != 자리 {
            let mut 목록 = 인접[선행]
            목록.push(자리)
            인접[선행] = 목록
            진입차수[자리] = 진입차수[자리] + 1
          } else if 선행 == 자리 {
            // 자기참조도 1개짜리 순환입니다. 자기 간선을 넣어야 순환소속이 잡습니다.
            // A self-reference is a one-cell cycle. Add the self-edge so 순환소속 detects it.
            let mut 목록 = 인접[자리]
            목록.push(자리)
            인접[자리] = 목록
            진입차수[자리] = 진입차수[자리] + 1
          }
        }
      }
    }
    자리 = 자리 + 1
  }

  // 위상 순서로 평가. 선행이 먼저 계산됩니다.
  // Evaluate in topological order so precedents are computed first.
  let mut 결과들: Array<평가> = []
  let mut 결초기 = 0
  while 결초기 < 개수 { 결과들.push(빈평가()); 결초기 = 결초기 + 1 }
  let mut 대기열: Array<int> = []
  let mut 첫 = 0
  while 첫 < 개수 { if 진입차수[첫] == 0 { 대기열.push(첫) }; 첫 = 첫 + 1 }
  let mut 머리 = 0
  let mut 처리수 = 0
  while 머리 < 대기열.length {
    let 현재 = 대기열[머리]
    머리 = 머리 + 1
    처리수 = 처리수 + 1
    결과들[현재] = 셀평가(셀들[현재].원본, 셀들, 결과들)
    for 의 in 인접[현재] {
      진입차수[의] = 진입차수[의] - 1
      if 진입차수[의] == 0 { 대기열.push(의) }
    }
  }

  let mut 순환목록 = ""
  if 처리수 < 개수 {
    let mut ㅅ = 0
    while ㅅ < 개수 {
      if 진입차수[ㅅ] > 0 {
        if 순환소속(ㅅ, 인접, 개수) {
          결과들[ㅅ] = 오류평가("순환")
          if 순환목록 != "" { 순환목록 = 순환목록 + ", " }
          순환목록 = 순환목록 + 줄정리(셀들[ㅅ].참조)
        } else {
          결과들[ㅅ] = 오류평가("순환에 의존")
        }
      }
      ㅅ = ㅅ + 1
    }
  }

  // 표 범위.
  // Grid bounds.
  let mut 최대열 = 0
  let mut 최대행 = 0
  let mut ㅂ = 0
  while ㅂ < 개수 {
    if 셀들[ㅂ].열 > 최대열 { 최대열 = 셀들[ㅂ].열 }
    if 셀들[ㅂ].행 > 최대행 { 최대행 = 셀들[ㅂ].행 }
    ㅂ = ㅂ + 1
  }

  let mut 출력 = ""
  if 순환목록 != "" {
    출력 = 출력 + "<div class=\"err\">순환 참조가 있습니다: {순환목록}</div>"
  }
  출력 = 출력 + "<table class=\"sheet\"><thead><tr><th></th>"
  let mut 열i = 0
  while 열i <= 최대열 { 출력 = 출력 + "<th>" + 열문자(열i) + "</th>"; 열i = 열i + 1 }
  출력 = 출력 + "</tr></thead><tbody>"
  let mut 행i = 1
  while 행i <= 최대행 {
    출력 = 출력 + "<tr><th>{행i}</th>"
    let mut ㄷ = 0
    while ㄷ <= 최대열 {
      let 참 = 열문자(ㄷ) + "{행i}"
      let 색 = 셀찾기(참, 셀들)
      let mut 칸 = ""
      if 색 >= 0 {
        let 결 = 결과들[색]
        match 결.종류 {
          case "수" => { 칸 = 표시(결.수) }
          case "글" => { 칸 = 줄정리(결.글) }
          case "오류" => { 칸 = "<span class=\"bad\">오류: " + 줄정리(결.오류) + "</span>" }
          case "빈" => {}
        }
      }
      출력 = 출력 + "<td>" + 칸 + "</td>"
      ㄷ = ㄷ + 1
    }
    출력 = 출력 + "</tr>"
    행i = 행i + 1
  }
  출력 = 출력 + "</tbody></table>"

  // JSON 직렬화(저장용). 원본 값/수식을 그대로 담습니다.
  // JSON serialization (for saving). Holds the raw values and formulas.
  let mut 제이 = "\{"
  let mut ㅈ = 0
  while ㅈ < 개수 {
    if ㅈ > 0 { 제이 = 제이 + "," }
    제이 = 제이 + "\"" + 제이슨값(셀들[ㅈ].참조) + "\":\"" + 제이슨값(셀들[ㅈ].원본) + "\""
    ㅈ = ㅈ + 1
  }
  제이 = 제이 + "\}"
  출력 = 출력 + "<div class=\"meta\">JSON 저장</div><pre class=\"out\">" + 줄정리(제이) + "</pre>"
  출력
}

function 셀평가(원본: string, 셀들: Array<셀>, 결과들: Array<평가>) -> 평가 {
  let 다듬 = 원본.trim()
  if 다듬 == "" { return 빈평가() }
  let 글자들 = 다듬.scalars()
  if 글자들[0] == "=" {
    let 식 = 꼬리(다듬, 1)
    let 토 = 토큰화(식)
    if 토.오류 != "" { return 오류평가(토.오류) }
    if 토.토큰들.length == 0 { return 오류평가("빈 수식") }
    // 값과 무관하게 구조를 먼저 검증합니다(구조=true). 그래야 0 나눗셈 같은 값 오류가
    // 뒤의 구문 오류(예: 끝의 연산자)를 가리지 않습니다.
    // Validate structure first, value-independent (구조=true), so a value error like a zero
    // division never masks a following syntax error such as a trailing operator.
    let 구검 = 비교식(토.토큰들, 0, 셀들, 결과들, true)
    if 구검.오류 != "" { return 오류평가(구검.오류) }
    if 구검.위치 != 토.토큰들.length { return 오류평가("수식이 올바르지 않습니다") }
    // 구조가 올바르면 실제 값으로 계산합니다(구조=false).
    // Structure is sound, so evaluate with real values (구조=false).
    let 평 = 비교식(토.토큰들, 0, 셀들, 결과들, false)
    if 평.오류 != "" { return 오류평가(평.오류) }
    return 수평가(평.값)
  }
  let 파 = 숫자파싱(다듬)
  if 파.오류 == "" { return 수평가(파.값) }
  글평가(다듬)
}

print(보기(input()))
AB
11060
2201
33020
JSON 저장
{"A1":"10","A2":"20","A3":"=A1+A2","B1":"=SUM(A1:A3)","B2":"=IF(B1 > 50, 1, 0)","B3":"=AVERAGE(A1:A3)"}