← 제니앱

시트

셀과 수식을 입력하면 의존성 순서로 계산하고, 순환 참조를 알리며, 표와 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,
//   열=column, 행=row, 참조=ref like A1, 원본=raw text, 수=number, 글=text,
//   오류=error, 토큰들=tokens, 위치=position, 결과들=results, 셀들=cells, 의존=dependency,
//   진입차수=in-degree, 인접=adjacency, 대기열=queue, 순서=topo order, 표시=display,
//   줄정리=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.

record 셀 { 열: 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 함수종류 = "SUM" | "COUNT" | "AVERAGE" | "MIN" | "MAX" | "ABS" | "IF"
enum 평가 { 수(float), 글(string), 빈, 오류(string) }
enum 토큰 { 수(string), 셀(string), 함수(string), 비교(string), 연산(string), 괄호열, 괄호닫, 콤마, 콜론 }

// 토큰 종류 검사와 글자 추출. 5.6.1엔 enum ==나 변형 검사 연산자가 없어 작은 match 헬퍼로 좁힌다.
// Token kind tests and glyph extraction. 5.6.1 has no enum == or variant-test operator, so narrow with small match helpers.
function 토큰글(토: 토큰) -> string {
  match 토 {
    case 수(g) => g
    case 셀(g) => g
    case 함수(g) => g
    case 비교(g) => g
    case 연산(g) => g
    case 괄호열 => "("
    case 괄호닫 => ")"
    case 콤마 => ","
    case 콜론 => ":"
  }
}
function 셀인가(토: 토큰) -> bool { match 토 { case 셀(_) => true; case _ => false } }
function 비교인가(토: 토큰) -> bool { match 토 { case 비교(_) => true; case _ => false } }
function 괄호열인가(토: 토큰) -> bool { match 토 { case 괄호열 => true; case _ => false } }
function 괄호닫인가(토: 토큰) -> bool { match 토 { case 괄호닫 => true; case _ => false } }
function 콤마인가(토: 토큰) -> bool { match 토 { case 콤마 => true; case _ => false } }
function 콜론인가(토: 토큰) -> bool { match 토 { case 콜론 => true; case _ => false } }
function 덧셈연산인가(토: 토큰) -> bool { match 토 { case 연산(g) => g == "+" || g == "-"; case _ => false } }
function 곱셈연산인가(토: 토큰) -> bool { match 토 { case 연산(g) => g == "*" || g == "/"; case _ => false } }

function 빈평가() -> 평가 { 평가.빈 }
function 수평가(수: float) -> 평가 { 평가.수(수) }
function 글평가(글: string) -> 평가 { 평가.글(글) }
function 오류평가(오류: string) -> 평가 { 평가.오류(오류) }

function 줄정리(원문: string) -> string {
  원문.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
}

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
  for 회 in 0..<엔 { 값 *= 10.0 }
  값
}

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

// 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 영뿐 = 소수.scalars().filter(글 => 글 != "0").length == 0
  if 영뿐 { return 정수 }
  let 자른소수 = 소수.slice(0, 4)
  // 끝자리 0 제거.
  // Strip trailing zeros.
  let 자글 = 자른소수.scalars()
  let mut 끝 = 자글.length
  while 끝 > 0 && 자글[끝 - 1] == "0" { 끝 -= 1 }
  if 끝 == 0 { return 정수 }
  정수 + "." + 자른소수.slice(0, 끝)
}

// 열 색인을 문자로(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
    결과 = (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) -> Result<{ 열: int, 행: int }, string> {
  let 글자들 = 참조.scalars()
  if 글자들.length == 0 { return Err("빈 참조") }
  let mut 자리 = 0
  let mut 열 = 0
  let mut 문자수 = 0
  while 자리 < 글자들.length && 영문인가(글자들[자리]) {
    let 대 = 대문자글(글자들[자리])
    let 코 = 대.codePointAt(0) ?? 0
    열 = 열 * 26 + (코 - 65 + 1)
    문자수 += 1
    자리 += 1
  }
  if 문자수 == 0 { return Err("잘못된 참조: {참조}") }
  let mut 숫자수 = 0
  let mut 행 = 0
  while 자리 < 글자들.length && 숫자인가(글자들[자리]) {
    행 = 행 * 10 + (글자들[자리].codePointAt(0) ?? 48) - 48
    숫자수 += 1
    자리 += 1
  }
  if 숫자수 == 0 || 자리 != 글자들.length || 행 == 0 {
    return Err("잘못된 참조: {참조}")
  }
  Ok({ 열: 열 - 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 {
  match 참조파싱(참조) {
    case Ok(검) => 열문자(검.열) + "{검.행}"
    case Err(_) => {
      let mut 출 = ""
      for 글 in 참조.scalars() { 출 += 대문자글(글) }
      출
    }
  }
}

// 앞 엔 글자를 뺀 나머지 문자열. 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 {
  for 자리 in 0..<셀들.length {
    if 셀들[자리].참조 == 참조 { return 자리 }
  }
  -1
}

// 수식을 토큰으로 나눕니다. 수, 셀참조, 함수이름, 연산자, 비교, 괄호, 콤마, 콜론.
// Tokenize a formula: numbers, cell refs, function names, operators, comparisons, parens, comma, colon.
function 토큰화(식: string) -> Result<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 Err("알 수 없는 문자: {글}")
    }
  }
  Ok(토큰들)
}

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

// 수식 토큰에서 참조하는 셀들을 모읍니다(범위는 펼침). 의존성 그래프용.
// Collect referenced cells from formula tokens (ranges expanded). For the dependency graph.
function 참조수집(토큰들: Array<토큰>) -> Set<string> {
  let mut 모음: Set<string> = Set.of()
  let mut 자리 = 0
  while 자리 < 토큰들.length {
    if 셀인가(토큰들[자리]) {
      let 이참조 = 토큰글(토큰들[자리])
      if 자리 + 2 < 토큰들.length && 콜론인가(토큰들[자리 + 1]) && 셀인가(토큰들[자리 + 2]) {
        match 범위펼치기(이참조, 토큰글(토큰들[자리 + 2])) {
          case Ok(펼친) => {
            for 참 in 펼친 {
              모음.add(참)
            }
          }
          case Err(_) => {}
        }
        자리 += 3
      } else {
        let 정규 = 참조정규화(이참조)
        모음.add(정규)
        자리 += 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) -> Result<float, string> {
  // 직접 참조도 범위처럼 검증합니다. 잘못된 참조(예: 행이 0인 A0)는 0이 아니라 오류.
  // Validate a direct ref like ranges do; a bad ref (e.g. A0 with row 0) errors instead of 0.
  match 참조파싱(참조) {
    case Ok(_) => {}
    case Err(메) => return Err(메)
  }
  // 구조 검증 모드: 유효한 참조는 값과 무관하게 더미 1로 봅니다.
  // Structural mode: a valid ref resolves to a dummy 1, independent of cell values.
  if 구조 { return Ok(1.0) }
  let 색인 = 셀찾기(참조정규화(참조), 셀들)
  if 색인 < 0 { return Ok(0.0) }
  let 결 = 결과들[색인]
  match 결 {
    case 오류(_) => Err("참조 셀 오류 {참조}")
    case 글(_) => Err("텍스트 셀 {참조}")
    case 빈 => Ok(0.0)
    case 수(값) => Ok(값)
  }
}

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

record 성공 { 값: float, 위치: int }

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

function 덧셈식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> Result<성공, string> {
  let mut 누적 = 곱셈식(토큰들, 위치, 셀들, 결과들, 구조)?
  let mut 위 = 누적.위치
  while 위 < 토큰들.length && 덧셈연산인가(토큰들[위]) {
    let 연 = 덧셈연산파싱(토큰글(토큰들[위]))?
    let 오 = 곱셈식(토큰들, 위 + 1, 셀들, 결과들, 구조)?
    let 값 = 덧셈계산(연, 누적.값, 오.값)
    누적 = 성공 { 값: 값, 위치: 오.위치 }
    위 = 오.위치
  }
  Ok(누적)
}

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

function 단항식(토큰들: Array<토큰>, 위치: int, 셀들: Array<셀>, 결과들: Array<평가>, 구조: bool) -> Result<성공, string> {
  if 위치 < 토큰들.length && 토큰글(토큰들[위치]) == "-" {
    let 안 = 단항식(토큰들, 위치 + 1, 셀들, 결과들, 구조)?
    return Ok(성공 { 값: 0.0 - 안.값, 위치: 안.위치 })
  }
  if 위치 < 토큰들.length && 토큰글(토큰들[위치]) == "+" {
    return 단항식(토큰들, 위치 + 1, 셀들, 결과들, 구조)
  }
  기본식(토큰들, 위치, 셀들, 결과들, 구조)
}

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

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

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

function 제이슨값(원: string) -> string {
  원.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\t", "\\t")
}

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 파 = match 참조파싱(참조) {
        case Ok(ㅍ) => ㅍ
        case Err(메) => 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>> = []
  for 준비 in 0..<개수 {
    진입차수.push(0)
    let mut 빈: Array<int> = []
    인접.push(빈)
  }
  // 구문 검사용 빈 스텁. 의존성 그래프를 만들기 전에 구문/참조 오류를 먼저 잡아,
  // 잘못된 수식(예: 괄호 안 닫힘)이 자기참조처럼 보여 가짜 순환으로 보고되지 않게 합니다.
  // 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<평가> = []
  for 스초기 in 0..<개수 { 스텁.push(빈평가()) }
  for 자리 in 0..<개수 {
    let 원본 = 셀들[자리].원본
    if 원본.scalars().length > 0 && 원본.scalars()[0] == "=" {
      let 식 = 꼬리(원본, 1)
      match 토큰화(식) {
        case Err(_) => {}
        case Ok(토큰목록) => {
          // 그래프 포함 여부는 구문/참조 형태로만 판단한다(값 평가로 하지 않는다). 빈 스텁에서 값
          // 의존 오류가 나도 잘 형성된 수식이면 포함해 위상 평가가 실제 선행 값으로 계산하게 한다.
          // 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 구문올바름 = match 비교식(토큰목록, 0, 셀들, 스텁, true) {
            case Ok(구검) => 구검.위치 == 토큰목록.length
            case Err(_) => false
          }
          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
              }
            }
          }
        }
      }
    }
  }

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

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

  // 표 범위.
  // Grid bounds.
  let mut 최대열 = 0
  let mut 최대행 = 0
  for 칸 in 셀들 {
    if 칸.열 > 최대열 { 최대열 = 칸.열 }
    if 칸.행 > 최대행 { 최대행 = 칸.행 }
  }

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

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

function 셀평가(원본: string, 셀들: Array<셀>, 결과들: Array<평가>) -> 평가 {
  let 다듬 = 원본.trim()
  if 다듬 == "" { return 빈평가() }
  let 글자들 = 다듬.scalars()
  if 글자들[0] == "=" {
    let 식 = 꼬리(다듬, 1)
    let 토큰목록 = match 토큰화(식) {
      case Ok(ㅌ) => ㅌ
      case Err(메) => 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 구검 = match 비교식(토큰목록, 0, 셀들, 결과들, true) {
      case Ok(ㄱ) => ㄱ
      case Err(메) => return 오류평가(메)
    }
    if 구검.위치 != 토큰목록.length { return 오류평가("수식이 올바르지 않습니다") }
    // 구조가 올바르면 실제 값으로 계산합니다(구조=false).
    // Structure is sound, so evaluate with real values (구조=false).
    let 평 = match 비교식(토큰목록, 0, 셀들, 결과들, false) {
      case Ok(ㅍ) => ㅍ
      case Err(메) => return 오류평가(메)
    }
    return 수평가(평.값)
  }
  match 숫자파싱(다듬) {
    case Ok(ㅇ) => 수평가(ㅇ)
    case Err(_) => 글평가(다듬)
  }
}

input() |> 보기 |> print
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)"}