From b3fee0bede8a0ae3ee4d8c000b87ca5325e742a1 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:45:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=EB=AC=B8=EC=84=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=9E=90=20=EC=96=B8=EC=96=B4(DSEL)=20=E2=80=94=20?= =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=A1=B0=EC=9E=91=20?= =?UTF-8?q?=EC=BB=A4=EB=84=90=201=EC=B8=B5=20(#4875)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에이전트가 문서를 고치는 길은 `edit` 하위 명령 6개가 전부이고, 여섯이 각자 다른 방식으로 대상을 지목한다(`--table/--row/--col`, `--data 이름=값[k]`, `--find/--occurrence`). "문서의 어디"를 가리키는 공통 개념이 없어서 "3절 두 번째 표의 마지막 행"을 표현할 문법 자체가 없다. CSS 선택자를 rhwp IR 에 맞춰 좁힌 지목 언어를 라이브러리 표면에 세운다. - 축 16종 — 전부 IR 의 실제 노드 종류. 지어낸 층 없음 - 결합자 4종, 비교자 10종, 의사 선택자 9종 - 노드 주소 `/section[0]/para[3]/cell[5]` — 참조가 아니라 값이라 편집을 사이에 두고 살아남는다(앵커 층의 전제). 사전식 순서가 곧 문서 순서 - 축·속성 사전은 한 벌뿐이고 파서·평가기·진단이 그것만 읽는다. 축 계층도 데이터로 적어 ontology 가 subClassOf 를 유도할 수 있게 했다 - 파싱 단계에서 축·속성·연산자 적합성까지 검사하고, 진단에 위치·기대 목록· 수복 힌트를 값으로 싣는다 - 정규식 대신 역추적 지점이 `*` 하나뿐인 글롭 — 최악 O(패턴×입력) 유계. 선택자가 맞대는 값은 문서에서 오고 문서는 신뢰 경계 밖이다 - 상한 8종(파싱 5·평가 3)을 전부 테스트로 고정. 중첩 깊이는 파서와 평가기 양쪽에서 막는다 — 파서를 거치지 않은 AST 로도 평가가 불릴 수 있다 - 손상 입력(어긋난 char_offsets, 비단조 start_pos, 짝 없는 서로게이트)에서 패닉 없이 진단으로 끝난다 기존 코드 변경은 `src/lib.rs` 에 `pub mod agent;` 한 줄뿐이다. 신규 4,611줄 / 단위 테스트 116건 / clippy 무경고 / rustfmt 통과. Co-Authored-By: Claude Opus 5 --- mydocs/report/task_dsel_selector_language.md | 229 ++++ src/agent/dsel/ast.rs | 1007 ++++++++++++++++ src/agent/dsel/error.rs | 237 ++++ src/agent/dsel/eval.rs | 1090 ++++++++++++++++++ src/agent/dsel/glob.rs | 385 +++++++ src/agent/dsel/lex.rs | 402 +++++++ src/agent/dsel/mod.rs | 120 ++ src/agent/dsel/node.rs | 617 ++++++++++ src/agent/dsel/parse.rs | 884 ++++++++++++++ src/agent/dsel/token.rs | 182 +++ src/agent/mod.rs | 88 ++ src/lib.rs | 1 + 12 files changed, 5242 insertions(+) create mode 100644 mydocs/report/task_dsel_selector_language.md create mode 100644 src/agent/dsel/ast.rs create mode 100644 src/agent/dsel/error.rs create mode 100644 src/agent/dsel/eval.rs create mode 100644 src/agent/dsel/glob.rs create mode 100644 src/agent/dsel/lex.rs create mode 100644 src/agent/dsel/mod.rs create mode 100644 src/agent/dsel/node.rs create mode 100644 src/agent/dsel/parse.rs create mode 100644 src/agent/dsel/token.rs create mode 100644 src/agent/mod.rs diff --git a/mydocs/report/task_dsel_selector_language.md b/mydocs/report/task_dsel_selector_language.md new file mode 100644 index 0000000000..0b9feda641 --- /dev/null +++ b/mydocs/report/task_dsel_selector_language.md @@ -0,0 +1,229 @@ +# DSEL — 문서 선택자 언어 (에이전트 조작 커널 1층) + +- 이슈: #4875 (로드맵 — 에이전트 문서 조작 커널) +- 브랜치: `agent_op_kernel` (upstream/devel `627c8c49a` 기준) +- 스코프: **1층 DSEL 만** — 문서의 "어디"를 값으로 만드는 층. 연산 대수(2층) + 이하와 CLI·MCP 표면은 후속 이슈로 쌓는다. + +## 무엇 + +`rhwp::agent::dsel` — CSS 선택자를 rhwp IR 에 맞춰 좁힌 문서 지목 언어. +라이브러리 표면(`pub mod agent`)에 선다. + +```rust +use rhwp::agent::dsel; + +let sel = dsel::parse("section:nth(2) > table:last cell[row=-1]")?; +let hits = dsel::select(&sel, &document)?; +for node in hits { + println!("{} = {:?}", node.id, node.node.text()); +} +// /section[2]/para[7]/control[0]/cell[11] = Some("합계") +``` + +### 문법 + +```text +selector := path ("," path)* +path := step (combinator step)* +comb := " " (자손) | ">" (직계) | "+" (다음 형제) | "~" (이후 형제) +step := axis predicate* +axis := section|para|run|control|table|cell|picture|equation + | field|footnote|endnote|header|footer|bookmark|hyperlink|shape|* +pred := "[" name (op value)? "]" | ":" pseudo ("(" arg ")")? +op := "=" | "!=" | ">" | "<" | ">=" | "<=" | "^=" | "$=" | "*=" | "~=" +pseudo := first|last|empty|nth(n)|range(a..b)|contains(s)|matches(s)|not(sel)|has(sel) +``` + +축 16종은 전부 IR 의 실제 노드 종류다. 지어낸 층은 없다. + +```text +Document + └ section Document::sections + └ para Section::paragraphs + ├ run Paragraph::char_shapes 가 나누는 구간 + └ control Paragraph::controls + ├ table Control::Table + │ └ cell Table::cells + │ └ para Cell::paragraphs ← 여기서 재귀한다 + ├ picture/equation/field/… + └ footnote/header/footer + └ para 각자의 문단 목록 +``` + +### 노드 주소 + +선택 결과는 참조가 아니라 **구조 경로**다. + +```text +/section[0]/para[3]/control[0]/cell[5]/para[0] +``` + +참조로 돌려주면 문서를 고치는 순간 죽는다. 커널의 요점이 "고르고 → 고치고 → +확인"이므로 선택 결과는 편집을 사이에 두고 살아남는 표현이어야 한다. 경로의 +**사전식 순서가 곧 문서 순서**라는 성질도 함께 얻는다(조상은 접두사, 앞 형제는 +작은 순번) — 결과 정렬에 별도 비교기가 필요 없다. + +## 판단 + +### 1. 사전은 하나뿐이다 + +축·속성·의사 선택자 목록은 `ast.rs` 의 상수 사전 한 벌이고, 파서·평가기·진단이 +전부 그것만 읽는다. 목록이 세 곳에 있으면 반드시 어긋난다 — +`capabilities`·`ir_schema`·`ontology` 를 전부 유도로 만든 것과 같은 이유다. + +축 계층(`table` 은 `control` 의 특수화)도 데이터로 적었다(`AxisKind::specializes`). +`ontology` 가 `rdfs:subClassOf` 를 유도할 때 손으로 계층을 다시 적지 않아도 되고, +"억지 계층 금지" 규약도 지켜진다 — 여기 적힌 관계는 평가기가 실제로 그렇게 +구현한 것뿐이다. `cell` 이 `table` 의 특수화가 **아닌** 것(포함 관계이지 +특수화가 아님)을 테스트가 고정한다. + +### 2. 파싱 중에 의미까지 검사한다 + +축 이름·속성 이름·연산자 적합성을 파싱 단계에서 확인한다. 문법만 보고 통과시킨 뒤 +평가에서 거절하면 오류 위치가 "이 선택자 어딘가"로 뭉개진다. + +``` +$ para[rows>1] +축 `para` 에 없는 속성 `rows` — 기대: controls | empty | index | len | shapeId | styleId | text +para[rows>1] + ^ +``` + +진단은 세 가지를 값으로 싣는다 — `offset`(문자 기준, 바이트 아님)·`expected`(닫힌 +후보 목록)·`hint`(관측된 오용의 교정). 선택자를 쓰는 쪽은 대부분 모델이고, +`"parse error"` 한 줄은 모델에게 "다시 추측하라"는 뜻이다. 추측 왕복 한 번이 +실패 한 번이다. + +### 3. 위치 의사 선택자는 결과 집합 기준, `[index]` 는 형제 기준 + +`:first`·`:last`·`:nth`·`:range` 는 **그 스텝의 결과 집합**에서 센다. +`table:last` 는 "문서에서 마지막으로 걸린 표"이지 "자기 문단의 마지막 표"가 아니다. + +CSS 의 `:last-child` 는 후자지만 에이전트가 쓰는 표현은 거의 언제나 전자다 +("마지막 표의 합계 행"). 형제 기준이 필요하면 `[index]` 속성이 그대로 남아 있다. +두 기준을 **다른 문법에** 두어 어느 쪽인지 항상 눈에 보이게 했다. + +값 술어를 위치 술어보다 **먼저** 적용한다. 순서가 뒤바뀌면 `table:last[rows>2]` 가 +"마지막 표를 고른 뒤 행 수를 본다"가 되어 마지막 표의 행이 둘 이하면 결과가 빈다. +사람이 뜻한 것은 거의 언제나 "행이 셋 이상인 표들 중 마지막"이다. + +### 4. 없는 값은 어떤 조건도 만족하지 않는다 + +속성이 없으면 `!=` 로도 참이 되지 않는다. `cell[name!="합계"]` 가 이름 없는 셀을 +전부 고르면, 이름을 붙이지 않은 셀이 갑자기 편집 대상이 되어 편집이 새어 나간다. +없는 것은 비교의 대상이 아니라는 규칙이 편집 안전에서 더 옳다. + +같은 이유로 빈 필드 이름은 값으로 내지 않는다 — 빈 문자열을 내면 `[name]` 존재 +검사가 참이 되어 "이름 있는 필드"를 잘못 센다. + +### 5. 결합자는 부모 색인 비교로 환원된다 + +평가는 문서를 문서 순서의 평평한 목록으로 한 번 펼친 뒤 그 위에서 접는다. +트리를 직접 재귀하면 결합자 네 종마다 다른 순회 방향이 필요해 코드가 네 갈래로 +갈라지는데, 펼쳐 두면 전부 부모 색인 비교가 된다. + +| 결합자 | 판정 | +| --- | --- | +| `>` | `flat[n].parent == Some(c)` | +| ` ` | `c` 가 `n` 의 조상 사슬에 있다 | +| `+` | 같은 부모·같은 종류·`n.index == c.index + 1` | +| `~` | 같은 부모·같은 종류·`n.index > c.index` | + +형제 판정이 **종류까지** 보는 것이 중요하다. 한 문단은 컨트롤과 구간을 함께 +갖는데, `+` 가 종류를 보지 않으면 컨트롤 다음에 오는 구간이 "다음 형제"가 되어 +글자 모양이 하나 바뀔 때마다 같은 선택자가 다른 것을 고른다. + +대가는 노드 수만큼의 메모리인데 상한으로 유계이므로, 손상·거대 입력에서도 +예측 가능한 실패(에러)로 끝난다 — 예측 불가능한 실패(OOM)가 아니라. + +### 6. 정규식을 쓰지 않는다 + +두 가지다. + +1. **의존성** — rhwp 는 정규식 크레이트를 쓰지 않는다. 선택자 하나 때문에 + 늘리면 wasm 크기와 감사 표면이 함께 는다. +2. **정지성** — 역추적 정규식은 입력에 따라 지수 시간으로 터진다. 선택자는 + **문서에서 온 값**과 맞대어지고 문서는 신뢰 경계 바깥이다(`provenance::MAP` 이 + 같은 말을 한다). 신뢰할 수 없는 입력에 지수 시간 판정기를 붙이는 것은 DoS 를 + 스스로 심는 것이다. + +그래서 문법을 글롭으로 좁히고 **역추적 지점이 `*` 하나뿐**인 고전 알고리즘을 쓴다. +연속 `*` 는 컴파일 때 하나로 접는다 — 접지 않으면 `***` 가 역추적 지점을 셋 +만들어 최악 시간이 패턴 길이에 곱해진다. + +### 7. 신뢰 경계 + +선택자는 문서 **밖에서** 오고(사람·계획서·모델), 선택자가 맞대는 값은 문서 +**안에서** 온다. 문서에서 읽은 문자열이 선택자 문법으로 재해석되는 경로는 +존재하지 않는다 — 문서에 `para:has(...)` 라고 적혀 있어도 그건 그냥 글자다. +이 성질을 테스트가 고정한다(`document_derived_text_is_never_reinterpreted_as_syntax`). + +## 상한 (전부 테스트로 고정) + +| 층 | 상한 | 값 | +| --- | --- | --- | +| 파싱 | 원문 길이 | 4096 자 | +| 파싱 | `:has`/`:not` 중첩 | 8 | +| 파싱 | 합집합 가지 | 64 | +| 파싱 | 경로 스텝 | 32 | +| 파싱 | 스텝당 술어 | 16 | +| 평가 | 펼칠 노드 | 200,000 | +| 평가 | 문서 중첩 깊이 | 64 | +| 평가 | 결과 | 50,000 | + +중첩 깊이는 파서와 평가기 **양쪽**에서 막는다. 파서를 거치지 않은 AST(계획서에서 +역직렬화한 선택자 등)로도 평가가 불릴 수 있기 때문이다. + +`count()` 는 `max_results` 에 막히지 않는다. 몇 개인지 알아야 상한을 넘겼는지 +판단할 수 있는데 세는 것조차 막히면 진단이 "너무 많다"에서 멈춰 몇 개인지 영영 +알 수 없다. + +## 손상 입력 방어 + +문단을 구간으로 자르는 경로(`runs_of`)가 실물 손상 문서를 만난다. 자를 수 없으면 +**잘못 자르는 대신 구간을 만들지 않는다** — 선택자가 아무것도 못 고르는 것은 +복구 가능하지만 패닉은 아니다. + +| 손상 | 처리 | +| --- | --- | +| `char_offsets` 길이가 텍스트와 어긋남 | UTF-16 위치를 글자 위치로 보되 범위를 조인다 | +| `start_pos` 가 비단조 | 뒤집힌 구간을 건너뛴다(역방향 슬라이스 = 패닉) | +| 첫 `start_pos` 가 0 이 아님 | 0 으로 끌어내린다(앞부분 유실 방지) | +| 짝 없는 서로게이트 | 렉싱 단계에서 힌트와 함께 거절 | +| 정수 오버플로 | 한국어 진단으로 거절(`parse::` 영문 메시지 흘리지 않음) | + +`?` 하나가 한글 한 글자에 대응하도록 전 구간이 `char` 단위다. 바이트 인덱스로 +돌면 오프셋이 글자 경계와 어긋나 캐럿이 글자 중간을 가리키고, 슬라이싱은 +`byte index is not a char boundary` 로 패닉한다. + +## 실측 + +- 신규 코드 **4,611 줄** (`src/agent/`), 단위 테스트 **116 건** 전부 통과 +- `cargo clippy --lib --all-targets` 무경고 +- `rustfmt --check` 통과 +- 기존 코드 변경: `src/lib.rs` 에 `pub mod agent;` 한 줄 추가뿐 + +| 파일 | 줄 | 역할 | +| --- | --- | --- | +| `agent/dsel/eval.rs` | 1,006 | 평가기 — 펼치기·결합자·술어·상한 | +| `agent/dsel/ast.rs` | 946 | 축·속성 사전, 축 계층, 오타 후보 | +| `agent/dsel/parse.rs` | 804 | 재귀 하강 파서 + 의미 검사 | +| `agent/dsel/node.rs` | 561 | 노드 주소·구간 분할·손상 방어 | +| `agent/dsel/lex.rs` | 364 | 문자 단위 렉서 | +| `agent/dsel/glob.rs` | 346 | 유계 글롭 판정기 | +| `agent/dsel/error.rs` | 216 | 진단(위치·기대·힌트) | +| `agent/dsel/token.rs` | 171 | 토큰 | +| `agent/dsel/mod.rs` | 112 | 공개 표면 | +| `agent/mod.rs` | 85 | 커널 아키텍처 문서 | + +## 남은 일 (후속 이슈) + +1. **연산 대수(2층)** — 역연산 가능한 편집 연산. 대상 지목은 이 층이 준다. +2. **앵커(3층)** — 편집 후 `NodeId` 재결속. 앞 형제가 사라지면 경로가 다른 것을 + 가리키는 문제를 푼다. +3. **검증·트랜잭션(4·5층)** — 사전·사후조건, 저널·롤백. +4. **정책·계보(6·7층)** — 연산별 능력 요구를 `policy_gate`·`agent_profiles` 에 잇는다. +5. **매니페스트(8층)** — `capabilities`·MCP 도구 자동 산출, CLI `rhwp agent` 표면. +6. **에이전트 축 모듈의 라이브러리 승격** — `main.rs` 의 `mod` 10개를 + `pub mod` 로 옮겨 `bindings/Native`·`tools/*`·wasm 이 링크할 수 있게 한다. diff --git a/src/agent/dsel/ast.rs b/src/agent/dsel/ast.rs new file mode 100644 index 0000000000..f73350bafe --- /dev/null +++ b/src/agent/dsel/ast.rs @@ -0,0 +1,1007 @@ +//! DSEL 구문 트리와 **축·속성 사전**. +//! +//! ## 사전이 왜 AST 옆에 있나 +//! +//! 축(`para`·`table`·`cell` …)과 그 축이 가진 속성은 세 곳에서 필요하다 — 파서가 +//! 오타를 후보와 함께 거절할 때, 평가기가 속성값을 뽑을 때, 스키마 산출기가 +//! 문법을 자기서술할 때. 셋이 각자 목록을 들면 그 셋은 반드시 어긋난다(rhwp 가 +//! `capabilities`·`ir_schema`·`ontology` 를 전부 **유도**로 만든 것과 같은 이유). +//! 그래서 목록은 여기 하나뿐이고, 나머지는 전부 이 사전을 읽는다. +//! +//! ## 축 계층이 사전에 들어 있는 이유 +//! +//! `table` 은 `control` 의 특수화다 — `control[kind=table]` 과 `table` 은 같은 것을 +//! 고른다. 이 관계를 [`AxisKind::specializes`] 로 **데이터로** 적어 두면 +//! `ontology` 가 `rdfs:subClassOf` 를 유도할 때 손으로 계층을 다시 적지 않아도 +//! 된다. 억지 계층을 만들지 않는다는 `ontology` 의 규약도 그대로 지켜진다 — +//! 여기 적힌 관계는 평가기가 실제로 그렇게 구현한 것뿐이다. +//! +//! ## 문서 트리의 실제 모양 +//! +//! 축은 rhwp IR 을 그대로 따른다. 지어낸 층은 없다. +//! +//! ```text +//! Document +//! └ section Document::sections +//! └ para Section::paragraphs +//! ├ run Paragraph::char_shapes 가 나누는 구간 +//! └ control Paragraph::controls +//! ├ table Control::Table +//! │ └ cell Table::cells +//! │ └ para Cell::paragraphs ← 여기서 재귀한다 +//! ├ picture/equation/field/… +//! └ footnote/header/footer +//! └ para 각자의 문단 목록 +//! ``` + +use super::error::SelectorError; + +/// 선택자 하나 — 쉼표로 이어진 경로들의 합집합. +/// +/// `source` 를 들고 다니는 이유: 평가 단계 오류도 캐럿을 그려야 하는데, 그때 +/// 원문이 없으면 오프셋만 있는 반쪽 진단이 된다. 선택자 문자열은 짧으므로 +/// 사본 비용보다 진단 품질이 이긴다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selector { + /// 합집합 가지들. 최소 하나. + pub paths: Vec, + /// 파싱한 원문. + pub source: String, +} + +impl Selector { + /// 이 선택자가 건드릴 수 있는 축의 집합 — 정책 게이트가 읽는다. + /// + /// 마지막 스텝의 축만 센다. 중간 스텝은 **경유**일 뿐 결과에 들어가지 않으므로, + /// 중간 축까지 요구 능력에 포함시키면 게이트가 실제보다 넓게 막는다 + /// (`table cell` 이 표 편집 능력을 요구하게 되는 식). + pub fn result_axes(&self) -> Vec { + let mut out: Vec = self + .paths + .iter() + .filter_map(|p| p.steps.last().map(|s| s.axis)) + .collect(); + out.sort_by_key(|a| a.name()); + out.dedup(); + out + } + + /// 중첩 선택자(`:has`·`:not`)까지 포함한 최대 중첩 깊이. + /// + /// 평가 상한(`Limit`)을 파싱 직후에 판정하려고 쓴다 — 깊이 폭발을 평가 중에 + /// 발견하면 이미 시간을 쓴 뒤다. + pub fn max_nesting(&self) -> usize { + self.paths + .iter() + .flat_map(|p| p.steps.iter()) + .flat_map(|s| s.preds.iter()) + .map(|p| match p { + Pred::Pseudo(Pseudo::Has(inner)) | Pred::Pseudo(Pseudo::Not(inner)) => { + 1 + inner.max_nesting() + } + _ => 0, + }) + .max() + .unwrap_or(0) + } +} + +/// 결합자로 이어진 스텝 열. 첫 스텝의 결합자는 항상 [`Combinator::Root`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Path { + pub steps: Vec, +} + +/// 경로의 한 마디 — 결합자 + 축 + 술어들. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Step { + /// 앞 스텝과의 관계. + pub combinator: Combinator, + /// 고를 노드 종류. + pub axis: Axis, + /// 걸러 낼 조건들. 순서는 원문 순서를 보존한다 — 평가 비용이 다른 술어를 + /// 재배치하는 최적화는 하지 않는다. 같은 선택자가 항상 같은 순서로 평가되어야 + /// 오류 보고 위치가 재현된다. + pub preds: Vec, + /// 원문에서 축 이름이 시작한 문자 오프셋. + pub offset: usize, +} + +/// 스텝 사이의 관계. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Combinator { + /// 경로의 첫 스텝 — 문서 루트에서 시작. + Root, + /// 공백 — 임의 깊이 자손. + Descendant, + /// `>` — 직계 자식. + Child, + /// `+` — 바로 다음 형제. + NextSibling, + /// `~` — 이후 모든 형제. + FollowingSibling, +} + +impl Combinator { + /// 원문 기호. `Root` 는 기호가 없다. + pub const fn symbol(self) -> &'static str { + match self { + Combinator::Root => "", + Combinator::Descendant => " ", + Combinator::Child => ">", + Combinator::NextSibling => "+", + Combinator::FollowingSibling => "~", + } + } +} + +/// 축 — 구체 종류 또는 전체. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + /// `*` — 종류를 가리지 않는다. + Any, + /// 이름 붙은 축. + Kind(AxisKind), +} + +impl Axis { + /// 축 이름 — `*` 포함. + pub const fn name(self) -> &'static str { + match self { + Axis::Any => "*", + Axis::Kind(k) => k.name(), + } + } + + /// 이 축에서 쓸 수 있는 속성 목록. + /// + /// `*` 는 **모든 축의 공통 속성만** 준다. 합집합을 주면 `*[rows>2]` 가 문법 + /// 오류를 통과하고 평가에서 조용히 아무것도 안 고르는 결과가 된다 — 선택자가 + /// 빈 결과를 낸 이유가 "그런 속성이 없어서"인지 "조건에 맞는 게 없어서"인지 + /// 구별할 수 없게 되는 것이 최악이다. + pub fn attributes(self) -> &'static [AttrDef] { + match self { + Axis::Any => COMMON_ATTRS, + Axis::Kind(k) => k.attributes(), + } + } +} + +/// 이름 붙은 축 — rhwp IR 의 실제 노드 종류. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum AxisKind { + /// `Document::sections` 의 한 구역. + Section, + /// 문단 — 구역·셀·각주·머리말 어디에 있든 같은 축이다. + Para, + /// 글자 모양이 같은 연속 구간 (`Paragraph::char_shapes` 가 나눈다). + Run, + /// `Paragraph::controls` 의 컨트롤 일반. + Control, + /// `Control::Table`. + Table, + /// `Table::cells` 의 셀. + Cell, + /// `Control::Picture`. + Picture, + /// `Control::Equation`. + Equation, + /// `Control::Field` — 누름틀·메모·하이퍼링크 등 필드 컨트롤. + Field, + /// `Control::Footnote`. + Footnote, + /// `Control::Endnote`. + Endnote, + /// `Control::Header`. + Header, + /// `Control::Footer`. + Footer, + /// `Control::Bookmark`. + Bookmark, + /// `Control::Hyperlink`. + Hyperlink, + /// `Control::Shape` — 그리기 개체. + Shape, +} + +/// 축 이름 ↔ 종류 대응표. 파서·스키마 산출기가 함께 읽는다. +pub const AXIS_NAMES: &[(&str, AxisKind)] = &[ + ("section", AxisKind::Section), + ("para", AxisKind::Para), + ("run", AxisKind::Run), + ("control", AxisKind::Control), + ("table", AxisKind::Table), + ("cell", AxisKind::Cell), + ("picture", AxisKind::Picture), + ("equation", AxisKind::Equation), + ("field", AxisKind::Field), + ("footnote", AxisKind::Footnote), + ("endnote", AxisKind::Endnote), + ("header", AxisKind::Header), + ("footer", AxisKind::Footer), + ("bookmark", AxisKind::Bookmark), + ("hyperlink", AxisKind::Hyperlink), + ("shape", AxisKind::Shape), +]; + +impl AxisKind { + /// 이름으로 축을 찾는다. + pub fn from_name(name: &str) -> Option { + AXIS_NAMES.iter().find(|(n, _)| *n == name).map(|(_, k)| *k) + } + + /// 안정 이름. + pub const fn name(self) -> &'static str { + match self { + AxisKind::Section => "section", + AxisKind::Para => "para", + AxisKind::Run => "run", + AxisKind::Control => "control", + AxisKind::Table => "table", + AxisKind::Cell => "cell", + AxisKind::Picture => "picture", + AxisKind::Equation => "equation", + AxisKind::Field => "field", + AxisKind::Footnote => "footnote", + AxisKind::Endnote => "endnote", + AxisKind::Header => "header", + AxisKind::Footer => "footer", + AxisKind::Bookmark => "bookmark", + AxisKind::Hyperlink => "hyperlink", + AxisKind::Shape => "shape", + } + } + + /// 한 줄 설명 — 스키마·`capabilities` 로 그대로 나간다. + pub const fn doc(self) -> &'static str { + match self { + AxisKind::Section => "구역 — 문서의 최상위 분할", + AxisKind::Para => "문단 — 구역·셀·각주·머리말 안 어디에 있든 같은 축", + AxisKind::Run => "글자 모양이 같은 연속 구간", + AxisKind::Control => "컨트롤 일반 — 표·그림·필드 등의 상위 축", + AxisKind::Table => "표", + AxisKind::Cell => "표의 셀", + AxisKind::Picture => "그림", + AxisKind::Equation => "수식", + AxisKind::Field => "필드 컨트롤 — 누름틀·메모 등", + AxisKind::Footnote => "각주", + AxisKind::Endnote => "미주", + AxisKind::Header => "머리말", + AxisKind::Footer => "꼬리말", + AxisKind::Bookmark => "책갈피", + AxisKind::Hyperlink => "하이퍼링크", + AxisKind::Shape => "그리기 개체", + } + } + + /// 이 축이 특수화하는 상위 축. + /// + /// `table` → `control` 처럼, `control` 로 고를 수 있는 것을 좁혀 고르는 관계만 + /// 적는다. `cell` 은 `table` 의 **자식**이지 특수화가 아니므로 여기 없다 — + /// 포함 관계와 특수화 관계를 섞으면 온톨로지가 거짓을 말한다. + pub const fn specializes(self) -> Option { + match self { + AxisKind::Table + | AxisKind::Picture + | AxisKind::Equation + | AxisKind::Field + | AxisKind::Footnote + | AxisKind::Endnote + | AxisKind::Header + | AxisKind::Footer + | AxisKind::Bookmark + | AxisKind::Hyperlink + | AxisKind::Shape => Some(AxisKind::Control), + _ => None, + } + } + + /// 이 축이 문단을 담을 수 있나 — 재귀 하강의 근거. + /// + /// 셀·각주·미주·머리말·꼬리말이 문단을 담는다는 것은 IR 의 사실이다 + /// (`Cell::paragraphs`, `Footnote::paragraphs` …). 평가기의 하강 규칙과 이 + /// 함수가 어긋나면 `table para` 가 셀 안 문단을 놓친다. + pub const fn holds_paragraphs(self) -> bool { + matches!( + self, + AxisKind::Section + | AxisKind::Cell + | AxisKind::Footnote + | AxisKind::Endnote + | AxisKind::Header + | AxisKind::Footer + ) + } + + /// 이 축의 속성 목록. + pub const fn attributes(self) -> &'static [AttrDef] { + match self { + AxisKind::Section => SECTION_ATTRS, + AxisKind::Para => PARA_ATTRS, + AxisKind::Run => RUN_ATTRS, + AxisKind::Control => CONTROL_ATTRS, + AxisKind::Table => TABLE_ATTRS, + AxisKind::Cell => CELL_ATTRS, + AxisKind::Field => FIELD_ATTRS, + AxisKind::Hyperlink => HYPERLINK_ATTRS, + AxisKind::Bookmark => BOOKMARK_ATTRS, + // 나머지 컨트롤은 아직 고유 속성이 없다 — 공통 속성만으로 고른다. + // "없다"를 빈 목록이 아니라 공통 목록으로 두는 이유: `picture[index=0]` + // 이 문법 오류가 되면 안 된다. + _ => CONTROL_ATTRS, + } + } +} + +/// 속성 하나의 정의. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AttrDef { + /// 선택자에 적는 이름. + pub name: &'static str, + /// 값 타입 — 비교 연산자 적합성 판정에 쓴다. + pub ty: AttrType, + /// 한 줄 설명. + pub doc: &'static str, +} + +/// 속성 값 타입. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttrType { + /// 문자열 — `=`·`!=`·`^=`·`$=`·`*=`·`~=` 가능. 대소 비교는 불가. + Str, + /// 정수 — 여섯 비교자 전부 가능하되 부분 일치는 불가. + Int, + /// 불리언 — `=`·`!=` 만. 속성 이름 단독은 `= true` 와 같다. + Bool, +} + +impl AttrType { + /// 이 타입에 이 연산자를 쓸 수 있나. + /// + /// 문자열에 `>=` 를 허용하지 않는 이유: 사전식 비교는 로캘에 따라 답이 달라진다. + /// 로캘 의존 결과는 커널의 결정론 규약을 깬다. + pub const fn accepts(self, op: CmpOp) -> bool { + match self { + AttrType::Str => !matches!(op, CmpOp::Gt | CmpOp::Lt | CmpOp::Ge | CmpOp::Le), + AttrType::Int => matches!( + op, + CmpOp::Eq | CmpOp::Ne | CmpOp::Gt | CmpOp::Lt | CmpOp::Ge | CmpOp::Le + ), + AttrType::Bool => matches!(op, CmpOp::Eq | CmpOp::Ne), + } + } + + /// 스키마용 이름. + pub const fn as_str(self) -> &'static str { + match self { + AttrType::Str => "string", + AttrType::Int => "integer", + AttrType::Bool => "boolean", + } + } +} + +/// 모든 축이 갖는 속성. +pub const COMMON_ATTRS: &[AttrDef] = &[AttrDef { + name: "index", + ty: AttrType::Int, + doc: "형제 중 0 기준 순번", +}]; + +const SECTION_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "구역 순번 (0 기준)", + }, + AttrDef { + name: "paras", + ty: AttrType::Int, + doc: "직계 문단 수", + }, +]; + +const PARA_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "형제 문단 중 순번 (0 기준)", + }, + AttrDef { + name: "text", + ty: AttrType::Str, + doc: "문단 텍스트 (제어문자 제외)", + }, + AttrDef { + name: "len", + ty: AttrType::Int, + doc: "텍스트 문자 수 (제어문자 제외)", + }, + AttrDef { + name: "styleId", + ty: AttrType::Int, + doc: "문단 스타일 ID", + }, + AttrDef { + name: "shapeId", + ty: AttrType::Int, + doc: "문단 모양 ID", + }, + AttrDef { + name: "empty", + ty: AttrType::Bool, + doc: "텍스트가 비었는가 (공백만 있어도 참)", + }, + AttrDef { + name: "controls", + ty: AttrType::Int, + doc: "직계 컨트롤 수", + }, +]; + +const RUN_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "문단 안 구간 순번 (0 기준)", + }, + AttrDef { + name: "text", + ty: AttrType::Str, + doc: "구간 텍스트", + }, + AttrDef { + name: "len", + ty: AttrType::Int, + doc: "구간 문자 수", + }, + AttrDef { + name: "charShapeId", + ty: AttrType::Int, + doc: "글자 모양 ID", + }, +]; + +const CONTROL_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "문단 안 컨트롤 순번 (0 기준)", + }, + AttrDef { + name: "kind", + ty: AttrType::Str, + doc: "컨트롤 종류 이름 — 축 이름과 같은 어휘", + }, + AttrDef { + name: "inline", + ty: AttrType::Bool, + doc: "글자처럼 취급되는가", + }, +]; + +const TABLE_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "문단 안 컨트롤 순번 (0 기준)", + }, + AttrDef { + name: "kind", + ty: AttrType::Str, + doc: "컨트롤 종류 이름", + }, + AttrDef { + name: "inline", + ty: AttrType::Bool, + doc: "글자처럼 취급되는가", + }, + AttrDef { + name: "rows", + ty: AttrType::Int, + doc: "행 수", + }, + AttrDef { + name: "cols", + ty: AttrType::Int, + doc: "열 수", + }, +]; + +const CELL_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "표 안 셀 순번 (행 우선, 0 기준)", + }, + AttrDef { + name: "row", + ty: AttrType::Int, + doc: "행 주소 (0 기준)", + }, + AttrDef { + name: "col", + ty: AttrType::Int, + doc: "열 주소 (0 기준)", + }, + AttrDef { + name: "rowSpan", + ty: AttrType::Int, + doc: "행 병합 개수", + }, + AttrDef { + name: "colSpan", + ty: AttrType::Int, + doc: "열 병합 개수", + }, + AttrDef { + name: "text", + ty: AttrType::Str, + doc: "셀 안 문단 텍스트를 개행으로 이은 값", + }, + AttrDef { + name: "header", + ty: AttrType::Bool, + doc: "제목 셀인가", + }, + AttrDef { + name: "name", + ty: AttrType::Str, + doc: "셀 필드 이름 (없으면 어떤 값과도 같지 않다)", + }, +]; + +const FIELD_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "문단 안 컨트롤 순번 (0 기준)", + }, + AttrDef { + name: "kind", + ty: AttrType::Str, + doc: "컨트롤 종류 이름", + }, + AttrDef { + name: "inline", + ty: AttrType::Bool, + doc: "글자처럼 취급되는가", + }, + AttrDef { + name: "name", + ty: AttrType::Str, + doc: "필드 이름 — CTRL_DATA 이름이 있으면 그것, 없으면 command", + }, + AttrDef { + name: "type", + ty: AttrType::Str, + doc: "필드 타입 이름", + }, +]; + +const HYPERLINK_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "문단 안 컨트롤 순번 (0 기준)", + }, + AttrDef { + name: "kind", + ty: AttrType::Str, + doc: "컨트롤 종류 이름", + }, + AttrDef { + name: "inline", + ty: AttrType::Bool, + doc: "글자처럼 취급되는가", + }, +]; + +const BOOKMARK_ATTRS: &[AttrDef] = &[ + AttrDef { + name: "index", + ty: AttrType::Int, + doc: "문단 안 컨트롤 순번 (0 기준)", + }, + AttrDef { + name: "kind", + ty: AttrType::Str, + doc: "컨트롤 종류 이름", + }, + AttrDef { + name: "inline", + ty: AttrType::Bool, + doc: "글자처럼 취급되는가", + }, + AttrDef { + name: "name", + ty: AttrType::Str, + doc: "책갈피 이름", + }, +]; + +/// 스텝의 술어 — 속성 조건 또는 의사 선택자. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pred { + Attr(AttrPred), + Pseudo(Pseudo), +} + +/// `[name op value]` 또는 `[name]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttrPred { + /// 속성 이름. + pub name: String, + /// 비교자와 값. `None` 이면 존재 검사 — 불리언은 참, 나머지는 "값이 있는가". + pub compare: Option<(CmpOp, Literal)>, + /// 속성 이름이 시작한 문자 오프셋. + pub offset: usize, +} + +/// 비교 연산자. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CmpOp { + Eq, + Ne, + Gt, + Lt, + Ge, + Le, + /// `^=` 접두. + Prefix, + /// `$=` 접미. + Suffix, + /// `*=` 부분. + Substr, + /// `~=` 글롭. + Glob, +} + +impl CmpOp { + pub const fn symbol(self) -> &'static str { + match self { + CmpOp::Eq => "=", + CmpOp::Ne => "!=", + CmpOp::Gt => ">", + CmpOp::Lt => "<", + CmpOp::Ge => ">=", + CmpOp::Le => "<=", + CmpOp::Prefix => "^=", + CmpOp::Suffix => "$=", + CmpOp::Substr => "*=", + CmpOp::Glob => "~=", + } + } +} + +/// 리터럴 값. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Literal { + Str(String), + Int(i64), + Bool(bool), +} + +impl Literal { + /// 스키마·오류 메시지용 타입 이름. + pub const fn type_name(&self) -> &'static str { + match self { + Literal::Str(_) => "string", + Literal::Int(_) => "integer", + Literal::Bool(_) => "boolean", + } + } +} + +/// 의사 선택자. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pseudo { + /// `:first` — 형제 중 첫째. + First, + /// `:last` — 형제 중 마지막. + Last, + /// `:nth(n)` — 0 기준 순번. 음수는 뒤에서 센다(`-1` = 마지막). + Nth(i64), + /// `:range(a..b)` — 0 기준 반열림 구간 `[a, b)`. 음수 인덱스 허용. + Range { from: i64, to: i64 }, + /// `:contains("…")` — 텍스트 부분 일치. + Contains(String), + /// `:matches("…")` — 글롭 일치. + Matches(String), + /// `:empty` — 텍스트가 비었다. + Empty, + /// `:not(sel)` — 중첩 선택자에 걸리지 않는다. + Not(Box), + /// `:has(sel)` — 자손 중에 중첩 선택자에 걸리는 것이 있다. + Has(Box), +} + +impl Pseudo { + /// 안정 이름. + pub const fn name(&self) -> &'static str { + match self { + Pseudo::First => "first", + Pseudo::Last => "last", + Pseudo::Nth(_) => "nth", + Pseudo::Range { .. } => "range", + Pseudo::Contains(_) => "contains", + Pseudo::Matches(_) => "matches", + Pseudo::Empty => "empty", + Pseudo::Not(_) => "not", + Pseudo::Has(_) => "has", + } + } +} + +/// 의사 선택자 하나의 정의 — 파서·스키마가 함께 읽는다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PseudoDef { + pub name: &'static str, + /// 인자 모양. + pub arity: PseudoArity, + pub doc: &'static str, +} + +/// 의사 선택자의 인자 모양. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PseudoArity { + /// 인자 없음 — 괄호를 쓰면 오류. + None, + /// 정수 하나. + Int, + /// 문자열 하나. + Str, + /// `a..b` 범위. + Range, + /// 중첩 선택자 하나. + Selector, +} + +/// 의사 선택자 사전. +pub const PSEUDO_DEFS: &[PseudoDef] = &[ + PseudoDef { + name: "first", + arity: PseudoArity::None, + doc: "형제 중 첫째", + }, + PseudoDef { + name: "last", + arity: PseudoArity::None, + doc: "형제 중 마지막", + }, + PseudoDef { + name: "empty", + arity: PseudoArity::None, + doc: "텍스트가 비었다 (공백만 있어도 참)", + }, + PseudoDef { + name: "nth", + arity: PseudoArity::Int, + doc: "0 기준 순번. 음수는 뒤에서 센다 (-1 = 마지막)", + }, + PseudoDef { + name: "range", + arity: PseudoArity::Range, + doc: "0 기준 반열림 구간 a..b. 음수 인덱스 허용", + }, + PseudoDef { + name: "contains", + arity: PseudoArity::Str, + doc: "텍스트 부분 일치", + }, + PseudoDef { + name: "matches", + arity: PseudoArity::Str, + doc: "글롭 일치 — * ? [abc] [a-z] [!abc]", + }, + PseudoDef { + name: "not", + arity: PseudoArity::Selector, + doc: "중첩 선택자에 걸리지 않는 것만", + }, + PseudoDef { + name: "has", + arity: PseudoArity::Selector, + doc: "자손 중 중첩 선택자에 걸리는 것이 있는 것만", + }, +]; + +impl PseudoDef { + pub fn from_name(name: &str) -> Option<&'static PseudoDef> { + PSEUDO_DEFS.iter().find(|d| d.name == name) + } +} + +/// 오타에 가장 가까운 후보를 찾는다 — 진단의 `hint` 로 나간다. +/// +/// 편집 거리 2 이내만 후보로 본다. 3 이상을 허용하면 `para` 의 후보로 `cell` 이 +/// 나오는 식이라 힌트가 오히려 방해가 된다. +/// +/// 구현은 제자리 Levenshtein — 후보가 십수 개뿐이라 자료구조를 더 얹을 이유가 없다. +pub fn nearest( + name: &str, + candidates: impl IntoIterator, +) -> Option<&'static str> { + let mut best: Option<(usize, &'static str)> = None; + for cand in candidates { + let d = edit_distance(name, cand); + if d > 2 { + continue; + } + // 같은 거리면 사전순 앞선 쪽 — 결정론을 위해서다. 후보 목록의 선언 순서에 + // 의존하면 사전을 재배치할 때 힌트가 조용히 바뀐다. + match best { + Some((bd, bc)) if bd < d || (bd == d && bc <= cand) => {} + _ => best = Some((d, cand)), + } + } + best.map(|(_, c)| c) +} + +/// 두 문자열의 Levenshtein 거리 (문자 단위). +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0usize; b.len() + 1]; + for (i, ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +/// 축 이름 오타를 후보와 함께 거절한다. +pub fn unknown_axis(name: &str, offset: usize) -> SelectorError { + let err = SelectorError::resolve(offset, format!("알 수 없는 축 `{name}`")) + .expecting(AXIS_NAMES.iter().map(|(n, _)| *n)); + match nearest(name, AXIS_NAMES.iter().map(|(n, _)| *n)) { + Some(c) => err.hinting(format!("`{c}` 를 뜻했나")), + None => err, + } +} + +/// 속성 이름 오타를 그 축의 후보와 함께 거절한다. +pub fn unknown_attr(axis: Axis, name: &str, offset: usize) -> SelectorError { + let names: Vec<&'static str> = axis.attributes().iter().map(|a| a.name).collect(); + let err = SelectorError::resolve( + offset, + format!("축 `{}` 에 없는 속성 `{name}`", axis.name()), + ) + .expecting(names.clone()); + if let Some(c) = nearest(name, names) { + return err.hinting(format!("`{c}` 를 뜻했나")); + } + // 축을 적지 않은 스텝은 `*` 이고 `*` 에는 공통 속성밖에 없다. 사람이 + // `para [len>0]` 처럼 공백을 넣으면 그 공백이 자손 결합자가 되어 뒤 스텝의 + // 축이 사라진다 — 오류는 속성에서 나지만 원인은 공백이므로 그렇게 적는다. + if axis == Axis::Any { + return err.hinting( + "축을 생략하면 `*` 이라 공통 속성만 쓸 수 있다 — 술어는 축에 붙여 `para[len>0]` 처럼 적는다 (공백은 자손 결합자다)", + ); + } + err +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_axis_name_round_trips() { + for (name, kind) in AXIS_NAMES { + assert_eq!(AxisKind::from_name(name), Some(*kind)); + assert_eq!(kind.name(), *name); + } + } + + #[test] + fn specialized_axes_expose_the_parent_attributes() { + // `table` 은 `control` 의 특수화이므로 control 의 속성을 전부 갖는다. + // 갖지 않으면 `control[kind=table]` 로는 되는데 `table` 로는 안 되는 + // 비대칭이 생긴다. + for attr in AxisKind::Control.attributes() { + assert!( + AxisKind::Table + .attributes() + .iter() + .any(|a| a.name == attr.name), + "table 축에 control 속성 `{}` 이 없다", + attr.name + ); + } + } + + #[test] + fn specialization_never_points_at_a_containment_parent() { + // cell 은 table 안에 있지만 table 의 특수화가 아니다. + assert_eq!(AxisKind::Cell.specializes(), None); + assert_eq!(AxisKind::Table.specializes(), Some(AxisKind::Control)); + } + + #[test] + fn wildcard_axis_exposes_only_common_attributes() { + let names: Vec<&str> = Axis::Any.attributes().iter().map(|a| a.name).collect(); + assert_eq!(names, vec!["index"]); + } + + #[test] + fn string_attributes_reject_ordering_operators() { + assert!(!AttrType::Str.accepts(CmpOp::Ge)); + assert!(AttrType::Str.accepts(CmpOp::Prefix)); + assert!(!AttrType::Int.accepts(CmpOp::Substr)); + assert!(AttrType::Bool.accepts(CmpOp::Ne)); + assert!(!AttrType::Bool.accepts(CmpOp::Gt)); + } + + #[test] + fn nearest_is_deterministic_under_ties() { + // 같은 거리 후보가 둘이면 사전순 앞선 쪽. 선언 순서를 뒤집어도 같아야 한다. + let forward = nearest("cellx", ["cell", "cells"]); + let backward = nearest("cellx", ["cells", "cell"]); + assert_eq!(forward, backward); + } + + #[test] + fn nearest_gives_up_beyond_distance_two() { + assert_eq!(nearest("표", AXIS_NAMES.iter().map(|(n, _)| *n)), None); + } + + #[test] + fn unknown_axis_suggests_the_close_one() { + let err = unknown_axis("tabel", 0); + assert_eq!(err.hint.as_deref(), Some("`table` 를 뜻했나")); + } + + #[test] + fn unknown_attr_scopes_candidates_to_the_axis() { + let err = unknown_attr(Axis::Kind(AxisKind::Cell), "rowspan", 5); + // 후보는 cell 의 속성뿐 — para 의 `text` 가 섞이더라도 cell 에도 있으니 + // 확인은 cell 고유 속성으로 한다. + assert!(err.expected.iter().any(|e| e == "rowSpan")); + assert!(!err.expected.iter().any(|e| e == "styleId")); + } + + #[test] + fn pseudo_defs_cover_every_pseudo_variant_name() { + let variants = [ + Pseudo::First, + Pseudo::Last, + Pseudo::Nth(0), + Pseudo::Range { from: 0, to: 1 }, + Pseudo::Contains(String::new()), + Pseudo::Matches(String::new()), + Pseudo::Empty, + ]; + for v in variants { + assert!( + PseudoDef::from_name(v.name()).is_some(), + "사전에 `{}` 가 없다", + v.name() + ); + } + assert!(PseudoDef::from_name("not").is_some()); + assert!(PseudoDef::from_name("has").is_some()); + } + + #[test] + fn paragraph_holders_match_the_ir_shape() { + // 문단을 담는 축은 IR 의 사실이다. 하나라도 빠지면 `table para` 가 + // 셀 안 문단을 놓친다. + assert!(AxisKind::Cell.holds_paragraphs()); + assert!(AxisKind::Section.holds_paragraphs()); + assert!(AxisKind::Footnote.holds_paragraphs()); + assert!(!AxisKind::Table.holds_paragraphs()); + assert!(!AxisKind::Para.holds_paragraphs()); + } +} diff --git a/src/agent/dsel/error.rs b/src/agent/dsel/error.rs new file mode 100644 index 0000000000..27dacb6770 --- /dev/null +++ b/src/agent/dsel/error.rs @@ -0,0 +1,237 @@ +//! DSEL 진단 — 위치·기대·수복 힌트를 값으로 낸다. +//! +//! ## 왜 문자열이 아니라 구조체인가 +//! +//! 선택자를 쓰는 쪽은 대부분 사람이 아니라 모델이다. `"parse error"` 한 줄은 +//! 모델에게 "다시 추측하라"는 뜻이고, 추측 왕복 한 번이 실패 한 번이다. CLI 가 +//! 이미 `수복: {"nextCall":…}` 규약으로 **다음 호출을 지목**하는 것과 같은 이유로, +//! 선택자 오류도 세 가지를 값으로 실어야 한다. +//! +//! - `offset` — 입력의 **문자(char)** 기준 위치. 바이트가 아니다. 한글 선택자 +//! (`para[style="제목 1"]`)에서 바이트 오프셋을 주면 캐럿이 글자 중간을 가리켜 +//! 화면에서 어긋난다. +//! - `expected` — 그 자리에서 받을 수 있었던 것의 **닫힌 목록**. "무엇이 틀렸다" +//! 보다 "무엇이었어야 했다"가 재시도를 한 번에 끝낸다. +//! - `hint` — 흔한 오용의 교정 문장. 추측이 아니라 실제로 관측된 오용만 적는다. +//! +//! ## 캐럿을 여기서 그리는 이유 +//! +//! [`SelectorError::render`] 가 캐럿 줄까지 만든다. 호출부(CLI·MCP·바인딩)마다 +//! 캐럿을 다시 그리면 오프셋 해석이 갈라지고, 갈라진 순간 어느 쪽이 맞는지 +//! 판정할 근거가 사라진다. 그리는 곳은 하나여야 한다. + +use std::fmt; + +/// 선택자 처리 중 발생한 오류. +/// +/// 렉싱·파싱·평가가 같은 타입을 쓴다. 세 단계를 나눠 봐야 소비자는 결국 +/// "선택자가 안 먹었다" 하나로 다루고, 나눈 만큼 `From` 변환만 늘어난다. +/// 단계 구분이 필요하면 [`SelectorErrorKind`] 로 본다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectorError { + /// 오류 갈래 — 기계 분기용. + pub kind: SelectorErrorKind, + /// 사람이 읽는 한 줄. 마침표로 끝내지 않는다(호출부가 문장을 이어 붙인다). + pub message: String, + /// 입력에서의 문자 오프셋. 입력 끝이면 `input.chars().count()`. + pub offset: usize, + /// 그 자리에서 허용됐던 것들. 비어 있을 수 있다(평가 단계 오류 등). + pub expected: Vec, + /// 교정 힌트. 관측된 오용에 대해서만 채운다. + pub hint: Option, +} + +/// 오류 갈래. +/// +/// `exitCode` 매핑은 호출부의 몫이다 — 커널은 프로세스 종료 코드를 모른다. +/// 다만 갈래는 "사용법 오류(2)"와 "실행 실패(1)"를 가를 수 있을 만큼은 나눠 둔다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectorErrorKind { + /// 토큰으로 쪼갤 수 없는 문자열 — 닫히지 않은 따옴표, 알 수 없는 기호. + Lex, + /// 토큰은 맞지만 문법이 아님 — 예상 밖 토큰, 조기 종료. + Parse, + /// 문법은 맞지만 뜻이 없음 — 없는 축, 그 축에 없는 속성, 인자 개수 불일치. + Resolve, + /// 평가 중 한계 초과 — 중첩 깊이, 결과 폭발 방지 상한. + Limit, +} + +impl SelectorErrorKind { + /// 갈래의 안정 문자열 이름 — 봉투 `error.kind` 로 그대로 나간다. + pub const fn as_str(self) -> &'static str { + match self { + SelectorErrorKind::Lex => "lex", + SelectorErrorKind::Parse => "parse", + SelectorErrorKind::Resolve => "resolve", + SelectorErrorKind::Limit => "limit", + } + } +} + +impl SelectorError { + /// 기본 생성자 — 기대 목록과 힌트는 빌더로 덧댄다. + pub fn new(kind: SelectorErrorKind, offset: usize, message: impl Into) -> Self { + SelectorError { + kind, + message: message.into(), + offset, + expected: Vec::new(), + hint: None, + } + } + + /// 렉싱 단계 오류. + pub fn lex(offset: usize, message: impl Into) -> Self { + Self::new(SelectorErrorKind::Lex, offset, message) + } + + /// 파싱 단계 오류. + pub fn parse(offset: usize, message: impl Into) -> Self { + Self::new(SelectorErrorKind::Parse, offset, message) + } + + /// 의미 해석 단계 오류. + pub fn resolve(offset: usize, message: impl Into) -> Self { + Self::new(SelectorErrorKind::Resolve, offset, message) + } + + /// 한계 초과. + pub fn limit(offset: usize, message: impl Into) -> Self { + Self::new(SelectorErrorKind::Limit, offset, message) + } + + /// 기대 목록을 단다. 여러 번 부르면 누적된다. + /// + /// 정렬·중복 제거를 여기서 하는 이유: 파서는 대안을 만나는 **순서대로** 기대를 + /// 쌓는데, 그 순서는 문법 규칙을 어떻게 적었느냐에 달린 구현 세부다. 소비자가 + /// 그 순서에 의존하면 문법을 리팩터링할 때마다 계약이 깨진다. + pub fn expecting(mut self, items: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.expected.extend(items.into_iter().map(Into::into)); + self.expected.sort(); + self.expected.dedup(); + self + } + + /// 교정 힌트를 단다. 이미 있으면 덮어쓴다(더 구체적인 쪽이 나중에 온다). + pub fn hinting(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + /// 캐럿 줄까지 포함한 사람용 렌더. + /// + /// 탭을 공백으로 바꾸지 않고 **그대로 흘린다** — 캐럿 줄에도 같은 탭을 넣으므로 + /// 터미널 탭 폭이 몇이든 캐럿은 맞는 칸에 선다. 공백으로 치환하면 탭 폭 8인 + /// 터미널에서 어긋난다. + pub fn render(&self, input: &str) -> String { + let mut out = String::new(); + out.push_str(&self.message); + if !self.expected.is_empty() { + out.push_str(" — 기대: "); + out.push_str(&self.expected.join(" | ")); + } + out.push('\n'); + out.push_str(input); + out.push('\n'); + for (i, ch) in input.chars().enumerate() { + if i >= self.offset { + break; + } + out.push(if ch == '\t' { '\t' } else { ' ' }); + } + out.push('^'); + if let Some(hint) = &self.hint { + out.push('\n'); + out.push_str("힌트: "); + out.push_str(hint); + } + out + } + + /// 봉투에 실을 JSON 표현. + /// + /// `offset` 을 항상 싣는 이유: 값이 0 이어도 "맨 앞에서 틀렸다"는 정보다. + /// 0 을 생략하면 소비자는 "위치 정보 없음"과 구별할 수 없다. + pub fn to_json(&self) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert("kind".into(), serde_json::json!(self.kind.as_str())); + obj.insert("message".into(), serde_json::json!(self.message)); + obj.insert("offset".into(), serde_json::json!(self.offset)); + if !self.expected.is_empty() { + obj.insert("expected".into(), serde_json::json!(self.expected)); + } + if let Some(hint) = &self.hint { + obj.insert("hint".into(), serde_json::json!(hint)); + } + serde_json::Value::Object(obj) + } +} + +impl fmt::Display for SelectorError { + /// 입력 없이 찍히는 자리(로그·`?` 전파)를 위한 한 줄. + /// + /// 캐럿은 입력이 있어야 그릴 수 있으므로 여기서는 오프셋을 숫자로 적는다. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "선택자 {}: {}", self.kind.as_str(), self.message)?; + if !self.expected.is_empty() { + write!(f, " (기대: {})", self.expected.join(" | "))?; + } + write!(f, " [문자 {}]", self.offset) + } +} + +impl std::error::Error for SelectorError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expected_is_sorted_and_deduped() { + let err = SelectorError::parse(3, "예상 밖 토큰") + .expecting(["]", "[", "]"]) + .expecting([":"]); + assert_eq!(err.expected, vec![":".to_string(), "[".into(), "]".into()]); + } + + #[test] + fn caret_lands_on_char_boundary_not_byte() { + // "한글" 뒤 세 번째 문자에서 오류 — 바이트로 세면 6, 문자로 세면 2. + let err = SelectorError::parse(2, "여기"); + let rendered = err.render("한글x"); + let caret_line = rendered.lines().last().unwrap(); + // 캐럿 앞 공백이 두 칸이어야 세 번째 글자를 가리킨다. + assert_eq!(caret_line, " ^"); + } + + #[test] + fn tabs_are_preserved_in_caret_line() { + let err = SelectorError::parse(2, "여기"); + let rendered = err.render("\t\tx"); + assert!(rendered.lines().last().unwrap().starts_with("\t\t")); + } + + #[test] + fn json_keeps_zero_offset() { + let json = SelectorError::lex(0, "닫히지 않은 따옴표").to_json(); + assert_eq!(json["offset"], serde_json::json!(0)); + assert_eq!(json["kind"], serde_json::json!("lex")); + // 비어 있는 기대·힌트는 싣지 않는다. + assert!(json.get("expected").is_none()); + assert!(json.get("hint").is_none()); + } + + #[test] + fn display_has_no_caret_but_carries_offset() { + let err = SelectorError::resolve(7, "없는 축").expecting(["para", "table"]); + let text = err.to_string(); + assert!(text.contains("[문자 7]")); + assert!(text.contains("para | table")); + assert!(!text.contains('^')); + } +} diff --git a/src/agent/dsel/eval.rs b/src/agent/dsel/eval.rs new file mode 100644 index 0000000000..3a9b9df006 --- /dev/null +++ b/src/agent/dsel/eval.rs @@ -0,0 +1,1090 @@ +//! DSEL 평가기 — 선택자를 실제 `Document` 에 먹인다. +//! +//! ## 왜 문서를 한 번 펼치나 +//! +//! 평가는 두 단계다. 먼저 문서를 **문서 순서의 평평한 목록**으로 펼치고 +//! (`flatten`), 그 위에서 선택자를 접는다. 트리를 직접 재귀하며 맞추는 구현도 +//! 가능하지만, 결합자 네 종(자손·직계·다음 형제·이후 형제)을 트리 재귀 안에서 +//! 구현하면 각 결합자마다 다른 순회 방향이 필요해 코드가 네 갈래로 갈라진다. +//! +//! 펼쳐 두면 결합자가 전부 **부모 색인 비교**로 환원된다. +//! +//! | 결합자 | 판정 | +//! | --- | --- | +//! | `>` | `flat[n].parent == Some(c)` | +//! | ` ` | `c` 가 `n` 의 조상 사슬에 있다 | +//! | `+` | 같은 부모·같은 종류·`n.index == c.index + 1` | +//! | `~` | 같은 부모·같은 종류·`n.index > c.index` | +//! +//! 네 줄이 전부이고, 각각을 따로 검증할 수 있다. 대가는 노드 수만큼의 메모리인데 +//! 상한([`EvalLimits::max_nodes`])으로 유계이므로 손상·거대 입력에서도 예측 가능한 +//! 실패(에러)로 끝난다 — 예측 불가능한 실패(OOM)가 아니라. +//! +//! ## 위치 의사 선택자의 기준점 +//! +//! `:first`·`:last`·`:nth`·`:range` 는 **그 스텝의 결과 집합**에서 센다. 형제 +//! 중에서 세지 않는다. 즉 `table:last` 는 "문서에서 마지막으로 걸린 표"이지 +//! "자기 문단의 마지막 표"가 아니다. +//! +//! CSS 의 `:last-child` 는 후자지만, 에이전트가 쓰는 표현은 거의 언제나 전자다 +//! ("마지막 표의 합계 행"). 형제 기준이 필요하면 `[index]` 속성이 그대로 남아 +//! 있다 — `table[index=0]` 은 자기 문단의 첫 표다. 두 기준을 **다른 문법에** +//! 두어 어느 쪽인지 항상 눈에 보이게 했다. +//! +//! ## 없는 값 +//! +//! 속성이 없으면 그 술어는 **어떤 연산자로도 참이 되지 않는다** — `!=` 도 +//! 마찬가지다. `cell[name!="합계"]` 가 이름 없는 셀을 전부 고르면, 이름을 붙이지 +//! 않은 셀이 갑자기 대상이 되어 편집이 새어 나간다. 없는 것은 비교의 대상이 +//! 아니라는 규칙이 편집 안전에서 더 옳다. + +use std::collections::HashSet; + +use crate::model::control::{Control, FieldType}; +use crate::model::document::Document; +use crate::model::paragraph::Paragraph; + +use super::ast::{AttrPred, Axis, CmpOp, Combinator, Literal, Path, Pred, Pseudo, Selector, Step}; +use super::error::SelectorError; +use super::glob::Glob; +use super::node::{ + control_kind_name, paragraphs_of_control, runs_of, Node, NodeId, NodeRef, NodeStep, PathStack, +}; + +/// 평가 상한. +/// +/// 상한을 기본값으로 두는 이유: 호출부가 상한을 **잊을 수 있기** 때문이다. +/// 기본이 무한이면 잊은 호출부가 곧 취약점이 된다. 기본이 유한하면 잊은 호출부는 +/// 그냥 정상 동작한다. +#[derive(Debug, Clone, Copy)] +pub struct EvalLimits { + /// 펼칠 수 있는 최대 노드 수. + pub max_nodes: usize, + /// 최대 트리 깊이 — 표 안의 표 안의 표…를 막는다. + pub max_depth: usize, + /// 돌려줄 수 있는 최대 결과 수. + pub max_results: usize, +} + +impl Default for EvalLimits { + fn default() -> Self { + EvalLimits { + // 실측 기준: 300쪽 공문서가 문단 1만 개 안쪽이다. 20만이면 그보다 + // 한 자릿수 위이므로 정상 문서를 막지 않으면서 폭주는 잡는다. + max_nodes: 200_000, + // 표 중첩은 한글에서도 실질 한계가 있다. 64 는 그보다 훨씬 깊다. + max_depth: 64, + max_results: 50_000, + } + } +} + +/// 펼쳐진 노드 하나. +struct Flat<'d> { + id: NodeId, + node: Node<'d>, + step: NodeStep, + /// 부모의 `flat` 색인. 구역은 부모가 없다. + parent: Option, + /// 같은 종류 형제 중 순번. + index: usize, + /// 같은 종류 형제의 총수. + sibling_count: usize, +} + +/// 선택자를 기본 상한으로 평가한다. +pub fn select<'d>(sel: &Selector, doc: &'d Document) -> Result>, SelectorError> { + select_with(sel, doc, EvalLimits::default()) +} + +/// 선택자를 주어진 상한으로 평가한다. +/// +/// 결과는 **문서 순서**로 정렬되고 중복이 없다. 합집합 가지 여럿이 같은 노드를 +/// 골라도 한 번만 나온다 — 편집이 같은 대상에 두 번 적용되는 사고를 여기서 막는다. +pub fn select_with<'d>( + sel: &Selector, + doc: &'d Document, + limits: EvalLimits, +) -> Result>, SelectorError> { + let flat = flatten(doc, &limits)?; + let hits = eval_paths(&sel.paths, &flat, &limits, 0)?; + + if hits.len() > limits.max_results { + return Err(SelectorError::limit( + 0, + format!( + "결과가 너무 많다 ({}건, 상한 {}건)", + hits.len(), + limits.max_results + ), + ) + .hinting("술어를 붙여 범위를 좁힌다")); + } + + Ok(hits + .into_iter() + .map(|i| NodeRef { + id: flat[i].id.clone(), + node: flat[i].node, + index: flat[i].index, + sibling_count: flat[i].sibling_count, + }) + .collect()) +} + +/// 선택자가 고른 노드 수만 센다 — 사전조건 검사가 쓴다. +/// +/// 결과를 만들지 않으므로 `max_results` 상한에 걸리지 않는다. "몇 개인지"를 +/// 알아야 상한을 넘겼는지 판단할 수 있는데, 세는 것조차 상한에 막히면 진단이 +/// "너무 많다"에서 멈춰 몇 개인지 영영 알 수 없다. +pub fn count<'d>( + sel: &Selector, + doc: &'d Document, + limits: EvalLimits, +) -> Result { + let flat = flatten(doc, &limits)?; + Ok(eval_paths(&sel.paths, &flat, &limits, 0)?.len()) +} + +// --------------------------------------------------------------------------- +// 펼치기 +// --------------------------------------------------------------------------- + +struct Walker<'d> { + out: Vec>, + stack: PathStack, + limits: EvalLimits, +} + +impl<'d> Walker<'d> { + /// 노드 하나를 밀어 넣고 그 색인을 돌려준다. + fn emit( + &mut self, + step: NodeStep, + node: Node<'d>, + parent: Option, + index: usize, + sibling_count: usize, + ) -> Result { + if self.out.len() >= self.limits.max_nodes { + return Err(SelectorError::limit( + 0, + format!("문서 노드가 너무 많다 (상한 {})", self.limits.max_nodes), + )); + } + self.stack.push(step); + let id = self.stack.snapshot(); + self.out.push(Flat { + id, + node, + step, + parent, + index, + sibling_count, + }); + Ok(self.out.len() - 1) + } + + fn check_depth(&self) -> Result<(), SelectorError> { + if self.stack.depth() > self.limits.max_depth { + return Err(SelectorError::limit( + 0, + format!("문서 중첩이 너무 깊다 (상한 {})", self.limits.max_depth), + )); + } + Ok(()) + } + + fn walk_paragraphs( + &mut self, + paras: &'d [Paragraph], + parent: Option, + ) -> Result<(), SelectorError> { + self.check_depth()?; + let total = paras.len(); + for (i, para) in paras.iter().enumerate() { + let me = self.emit(NodeStep::Para(i as u32), Node::Para(para), parent, i, total)?; + self.walk_para_children(para, me)?; + self.stack.pop(); + } + Ok(()) + } + + /// 문단의 자식 — 컨트롤과 구간. 순번은 종류별로 따로 센다. + fn walk_para_children( + &mut self, + para: &'d Paragraph, + parent: usize, + ) -> Result<(), SelectorError> { + self.check_depth()?; + + let control_total = para.controls.len(); + for (i, control) in para.controls.iter().enumerate() { + let me = self.emit( + NodeStep::Control(i as u32), + Node::Control(control), + Some(parent), + i, + control_total, + )?; + self.walk_control_children(control, me)?; + self.stack.pop(); + } + + let runs = runs_of(para); + let run_total = runs.len(); + for (i, run) in runs.into_iter().enumerate() { + self.emit( + NodeStep::Run(i as u32), + Node::Run(run), + Some(parent), + i, + run_total, + )?; + self.stack.pop(); + } + + Ok(()) + } + + fn walk_control_children( + &mut self, + control: &'d Control, + parent: usize, + ) -> Result<(), SelectorError> { + self.check_depth()?; + + if let Control::Table(table) = control { + let total = table.cells.len(); + for (i, cell) in table.cells.iter().enumerate() { + let me = self.emit( + NodeStep::Cell(i as u32), + Node::Cell(cell), + Some(parent), + i, + total, + )?; + self.walk_paragraphs(&cell.paragraphs, Some(me))?; + self.stack.pop(); + } + return Ok(()); + } + + if let Some(paras) = paragraphs_of_control(control) { + self.walk_paragraphs(paras, Some(parent))?; + } + Ok(()) + } +} + +/// 문서를 문서 순서의 평평한 목록으로 펼친다. +/// +/// 결과가 문서 순서로 **이미 정렬되어 있다**는 것이 뒤 단계의 전제다. 전위 +/// 순회로 밀어 넣으므로 조상은 항상 자손보다 앞서고, 앞 형제는 뒤 형제보다 +/// 앞선다 — 이는 `NodeId` 의 사전식 순서와 정확히 같다(`node` 모듈 참조). +fn flatten<'d>(doc: &'d Document, limits: &EvalLimits) -> Result>, SelectorError> { + let mut w = Walker { + out: Vec::new(), + stack: PathStack::new(), + limits: *limits, + }; + let total = doc.sections.len(); + for (i, section) in doc.sections.iter().enumerate() { + let me = w.emit( + NodeStep::Section(i as u32), + Node::Section(section), + None, + i, + total, + )?; + w.walk_paragraphs(§ion.paragraphs, Some(me))?; + w.stack.pop(); + } + Ok(w.out) +} + +// --------------------------------------------------------------------------- +// 접기 +// --------------------------------------------------------------------------- + +/// 합집합 가지 전체를 평가하고 문서 순서로 합친다. +fn eval_paths( + paths: &[Path], + flat: &[Flat<'_>], + limits: &EvalLimits, + depth: usize, +) -> Result, SelectorError> { + // 파서가 중첩 깊이를 이미 막지만, 평가기도 스스로를 지킨다. 두 곳 다 + // 막는 이유는 `eval` 이 파서를 거치지 않은 AST 로도 불릴 수 있기 때문이다 + // (계획서에서 역직렬화한 선택자 등). + if depth > super::parse::MAX_NESTING { + return Err(SelectorError::limit( + 0, + format!( + "중첩 선택자가 너무 깊다 (상한 {})", + super::parse::MAX_NESTING + ), + )); + } + + let mut seen = vec![false; flat.len()]; + for path in paths { + for i in eval_path(path, flat, limits, depth)? { + seen[i] = true; + } + } + Ok((0..flat.len()).filter(|&i| seen[i]).collect()) +} + +/// 한 경로를 왼쪽에서 오른쪽으로 접는다. +fn eval_path( + path: &Path, + flat: &[Flat<'_>], + limits: &EvalLimits, + depth: usize, +) -> Result, SelectorError> { + let mut current: Vec = Vec::new(); + + for (si, step) in path.steps.iter().enumerate() { + let candidates = if si == 0 { + // 첫 스텝은 문서 어디서든 시작한다 — 뿌리의 자손 전부가 후보다. + (0..flat.len()) + .filter(|&i| flat[i].node.matches_axis(step.axis)) + .collect() + } else { + reachable(step.combinator, ¤t, flat, step.axis) + }; + + current = apply_preds(candidates, step, flat, limits, depth)?; + if current.is_empty() { + break; + } + } + + Ok(current) +} + +/// 결합자로 도달 가능한 후보들. +fn reachable(comb: Combinator, current: &[usize], flat: &[Flat<'_>], axis: Axis) -> Vec { + let mut in_current = vec![false; flat.len()]; + for &i in current { + in_current[i] = true; + } + + // 형제 결합자는 (부모, 종류) 별로 현재 집합의 순번을 봐야 한다. 후보마다 + // `current` 를 전부 훑으면 O(후보 × 현재)가 되므로, 한 번만 접어 둔다. + let mut sib_exact: HashSet<(usize, u8, usize)> = HashSet::new(); + let mut sib_min: std::collections::HashMap<(usize, u8), usize> = + std::collections::HashMap::new(); + if matches!(comb, Combinator::NextSibling | Combinator::FollowingSibling) { + for &c in current { + let Some(p) = flat[c].parent else { continue }; + let key = (p, flat[c].step.kind_ord()); + sib_exact.insert((p, flat[c].step.kind_ord(), flat[c].index)); + sib_min + .entry(key) + .and_modify(|m| *m = (*m).min(flat[c].index)) + .or_insert(flat[c].index); + } + } + + (0..flat.len()) + .filter(|&n| flat[n].node.matches_axis(axis)) + .filter(|&n| match comb { + Combinator::Root => true, + Combinator::Child => flat[n].parent.is_some_and(|p| in_current[p]), + Combinator::Descendant => { + let mut cur = flat[n].parent; + while let Some(p) = cur { + if in_current[p] { + return true; + } + cur = flat[p].parent; + } + false + } + Combinator::NextSibling => { + let Some(p) = flat[n].parent else { + return false; + }; + flat[n].index > 0 + && sib_exact.contains(&(p, flat[n].step.kind_ord(), flat[n].index - 1)) + } + Combinator::FollowingSibling => { + let Some(p) = flat[n].parent else { + return false; + }; + sib_min + .get(&(p, flat[n].step.kind_ord())) + .is_some_and(|&m| m < flat[n].index) + } + }) + .collect() +} + +/// 술어를 적용한다 — 값 술어 먼저, 위치 술어 나중. +/// +/// 순서가 뒤바뀌면 `table:last[rows>2]` 가 "마지막 표를 고른 뒤 행 수를 본다"가 +/// 되어, 마지막 표의 행이 둘 이하면 결과가 빈다. 사람이 뜻한 것은 거의 언제나 +/// "행이 셋 이상인 표들 중 마지막"이다. +fn apply_preds( + candidates: Vec, + step: &Step, + flat: &[Flat<'_>], + limits: &EvalLimits, + depth: usize, +) -> Result, SelectorError> { + let mut survivors = candidates; + + // 1단계 — 값 술어. + for pred in &step.preds { + if survivors.is_empty() { + break; + } + match pred { + Pred::Attr(attr) => { + let glob = compile_glob_for(attr)?; + survivors.retain(|&n| attr_matches(&flat[n], attr, glob.as_ref())); + } + Pred::Pseudo(Pseudo::Contains(needle)) => { + survivors.retain(|&n| { + flat[n] + .node + .text() + .is_some_and(|t| visible(&t).contains(needle.as_str())) + }); + } + Pred::Pseudo(Pseudo::Matches(pattern)) => { + let g = Glob::compile(pattern, step.offset)?; + survivors.retain(|&n| { + flat[n] + .node + .text() + .is_some_and(|t| g.is_match(&visible(&t))) + }); + } + Pred::Pseudo(Pseudo::Empty) => { + // 텍스트 개념이 없는 노드(그림 등)는 "비었다"가 성립하지 않는다. + survivors.retain(|&n| flat[n].node.text().is_some_and(|t| visible(&t).is_empty())); + } + Pred::Pseudo(Pseudo::Not(inner)) => { + let hits = eval_paths(&inner.paths, flat, limits, depth + 1)?; + let set: HashSet = hits.into_iter().collect(); + survivors.retain(|n| !set.contains(n)); + } + Pred::Pseudo(Pseudo::Has(inner)) => { + let hits = eval_paths(&inner.paths, flat, limits, depth + 1)?; + // 후보마다 전체 결과를 훑지 않도록, 결과의 조상 사슬을 한 번에 + // 접어 "자손을 가진 노드"의 집합으로 만든다. + let mut has_desc = vec![false; flat.len()]; + for h in hits { + let mut cur = flat[h].parent; + while let Some(p) = cur { + if has_desc[p] { + break; // 위쪽은 이미 표시됐다. + } + has_desc[p] = true; + cur = flat[p].parent; + } + } + survivors.retain(|&n| has_desc[n]); + } + // 위치 술어는 2단계에서. + Pred::Pseudo(Pseudo::First | Pseudo::Last | Pseudo::Nth(_) | Pseudo::Range { .. }) => {} + } + } + + // 2단계 — 위치 술어. 선언 순서대로 차례로 좁힌다. + for pred in &step.preds { + if survivors.is_empty() { + break; + } + let Pred::Pseudo(p) = pred else { continue }; + let len = survivors.len(); + match p { + Pseudo::First => survivors.truncate(1), + Pseudo::Last => { + let last = survivors[len - 1]; + survivors.clear(); + survivors.push(last); + } + Pseudo::Nth(n) => { + survivors = match resolve_index(*n, len) { + Some(i) => vec![survivors[i]], + None => Vec::new(), + }; + } + Pseudo::Range { from, to } => { + let lo = clamp_index(*from, len); + let hi = clamp_index(*to, len); + survivors = if lo < hi { + survivors[lo..hi].to_vec() + } else { + Vec::new() + }; + } + _ => {} + } + } + + Ok(survivors) +} + +/// 음수 인덱스를 뒤에서 센 위치로 바꾼다. 범위를 벗어나면 `None`. +fn resolve_index(n: i64, len: usize) -> Option { + if len == 0 { + return None; + } + let len_i = len as i64; + let idx = if n < 0 { len_i + n } else { n }; + if idx < 0 || idx >= len_i { + return None; + } + Some(idx as usize) +} + +/// 범위 끝점을 0..=len 으로 조인다. +/// +/// `resolve_index` 와 달리 범위를 벗어나도 실패하지 않는다 — `:range(0..999)` 는 +/// "있는 만큼 전부"라는 뜻으로 읽는 편이 쓰는 사람의 의도에 맞는다. +fn clamp_index(n: i64, len: usize) -> usize { + let len_i = len as i64; + let idx = if n < 0 { len_i + n } else { n }; + idx.clamp(0, len_i) as usize +} + +/// 글롭 비교자라면 패턴을 미리 컴파일한다. +fn compile_glob_for(attr: &AttrPred) -> Result, SelectorError> { + match &attr.compare { + Some((CmpOp::Glob, Literal::Str(pattern))) => { + Ok(Some(Glob::compile(pattern, attr.offset)?)) + } + _ => Ok(None), + } +} + +// --------------------------------------------------------------------------- +// 속성 +// --------------------------------------------------------------------------- + +/// 속성값 하나. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AttrValue { + Str(String), + Int(i64), + Bool(bool), +} + +/// 제어문자를 뺀 텍스트. +/// +/// HWP 본문에는 컨트롤 자리를 표시하는 제어문자가 섞여 있다. 그대로 두면 +/// `[text="합계"]` 가 눈에 보이기로는 "합계"인 문단에 걸리지 않는다 — 보이지 않는 +/// 글자 때문에 선택자가 빗나가는 것은 진단조차 어렵다. +fn visible(text: &str) -> String { + text.chars().filter(|c| !c.is_control()).collect() +} + +/// 노드에서 속성값을 뽑는다. 없으면 `None`. +fn attr_of(flat: &Flat<'_>, name: &str) -> Option { + if name == "index" { + return Some(AttrValue::Int(flat.index as i64)); + } + + match flat.node { + Node::Section(s) => match name { + "paras" => Some(AttrValue::Int(s.paragraphs.len() as i64)), + _ => None, + }, + Node::Para(p) => match name { + "text" => Some(AttrValue::Str(visible(&p.text))), + "len" => Some(AttrValue::Int(visible(&p.text).chars().count() as i64)), + "styleId" => Some(AttrValue::Int(i64::from(p.style_id))), + "shapeId" => Some(AttrValue::Int(i64::from(p.para_shape_id))), + "empty" => Some(AttrValue::Bool(visible(&p.text).trim().is_empty())), + "controls" => Some(AttrValue::Int(p.controls.len() as i64)), + _ => None, + }, + Node::Run(r) => match name { + "text" => Some(AttrValue::Str(visible(r.text()))), + "len" => Some(AttrValue::Int(visible(r.text()).chars().count() as i64)), + "charShapeId" => Some(AttrValue::Int(i64::from(r.char_shape_id()))), + _ => None, + }, + Node::Cell(c) => match name { + "row" => Some(AttrValue::Int(i64::from(c.row))), + "col" => Some(AttrValue::Int(i64::from(c.col))), + "rowSpan" => Some(AttrValue::Int(i64::from(c.row_span))), + "colSpan" => Some(AttrValue::Int(i64::from(c.col_span))), + "header" => Some(AttrValue::Bool(c.is_header)), + "name" => c.field_name.as_ref().map(|n| AttrValue::Str(n.clone())), + "text" => Some(AttrValue::Str(visible( + &c.paragraphs + .iter() + .map(|p| p.text.as_str()) + .collect::>() + .join("\n"), + ))), + _ => None, + }, + Node::Control(control) => attr_of_control(control, name), + } +} + +fn attr_of_control(control: &Control, name: &str) -> Option { + match name { + "kind" => return Some(AttrValue::Str(control_kind_name(control).to_string())), + "inline" => return Some(AttrValue::Bool(control.is_treat_as_char_object())), + _ => {} + } + + match control { + Control::Table(t) => match name { + "rows" => Some(AttrValue::Int(i64::from(t.row_count))), + "cols" => Some(AttrValue::Int(i64::from(t.col_count))), + _ => None, + }, + Control::Field(f) => match name { + // 이름은 CTRL_DATA 쪽이 우선이다 — 누름틀 고치기가 여기에 쓰고, + // `command` 는 안내문이라 사용자가 보는 이름과 다를 수 있다. + "name" => { + let raw = f.ctrl_data_name.as_deref().unwrap_or(f.command.as_str()); + // 빈 이름은 이름이 없는 것과 같다 — 빈 문자열을 값으로 내면 + // `[name]` 존재 검사가 참이 되어 "이름 있는 필드"를 잘못 센다. + if raw.is_empty() { + None + } else { + Some(AttrValue::Str(raw.to_string())) + } + } + "type" => Some(AttrValue::Str(field_type_name(f.field_type).to_string())), + _ => None, + }, + Control::Bookmark(b) => match name { + "name" if !b.name.is_empty() => Some(AttrValue::Str(b.name.clone())), + _ => None, + }, + _ => None, + } +} + +/// 필드 타입의 안정 이름. +/// +/// `Debug` 로 찍지 않는 이유: `Debug` 출력은 계약이 아니다. 변종 이름을 리팩터링 +/// 하면 선택자가 조용히 안 맞게 된다. 여기 적힌 문자열이 계약이다. +fn field_type_name(ty: FieldType) -> &'static str { + match ty { + FieldType::Unknown => "unknown", + FieldType::Date => "date", + FieldType::DocDate => "docDate", + FieldType::Path => "path", + FieldType::Bookmark => "bookmark", + FieldType::MailMerge => "mailMerge", + FieldType::CrossRef => "crossRef", + FieldType::Formula => "formula", + FieldType::ClickHere => "clickHere", + FieldType::Summary => "summary", + FieldType::UserInfo => "userInfo", + FieldType::Hyperlink => "hyperlink", + FieldType::Memo => "memo", + FieldType::PrivateInfoSecurity => "privateInfoSecurity", + FieldType::TableOfContents => "tableOfContents", + } +} + +/// 속성 술어 하나를 판정한다. +fn attr_matches(flat: &Flat<'_>, pred: &AttrPred, glob: Option<&Glob>) -> bool { + let Some(value) = attr_of(flat, &pred.name) else { + // 없는 값은 어떤 조건도 만족하지 않는다 — `!=` 도 포함. 모듈 문서 참조. + return false; + }; + + let Some((op, literal)) = &pred.compare else { + // 존재 검사. 불리언은 값 자체가 판정이다 — `[header]` 는 `[header=true]`. + return match value { + AttrValue::Bool(b) => b, + _ => true, + }; + }; + + match (&value, literal) { + (AttrValue::Int(a), Literal::Int(b)) => match op { + CmpOp::Eq => a == b, + CmpOp::Ne => a != b, + CmpOp::Gt => a > b, + CmpOp::Lt => a < b, + CmpOp::Ge => a >= b, + CmpOp::Le => a <= b, + // 파서가 이미 막지만, 파서를 거치지 않은 AST 도 안전해야 한다. + _ => false, + }, + (AttrValue::Bool(a), Literal::Bool(b)) => match op { + CmpOp::Eq => a == b, + CmpOp::Ne => a != b, + _ => false, + }, + (AttrValue::Str(a), Literal::Str(b)) => match op { + CmpOp::Eq => a == b, + CmpOp::Ne => a != b, + CmpOp::Prefix => a.starts_with(b.as_str()), + CmpOp::Suffix => a.ends_with(b.as_str()), + CmpOp::Substr => a.contains(b.as_str()), + CmpOp::Glob => glob.is_some_and(|g| g.is_match(a)), + _ => false, + }, + // 타입이 어긋난 비교는 참이 될 수 없다. + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::dsel::parse; + use crate::model::control::{Bookmark, Field, FieldType}; + use crate::model::document::Section; + use crate::model::paragraph::CharShapeRef; + use crate::model::table::{Cell, Table}; + + fn para(text: &str) -> Paragraph { + let mut p = Paragraph { + text: text.to_string(), + ..Default::default() + }; + let mut utf16 = 0u32; + for ch in text.chars() { + p.char_offsets.push(utf16); + utf16 += ch.len_utf16() as u32; + } + p + } + + fn para_styled(text: &str, style_id: u8) -> Paragraph { + let mut p = para(text); + p.style_id = style_id; + p + } + + fn cell(row: u16, col: u16, text: &str) -> Cell { + Cell { + row, + col, + row_span: 1, + col_span: 1, + paragraphs: vec![para(text)], + ..Default::default() + } + } + + /// 2×2 표 하나를 담은 문단. + fn table_para(rows: u16, cols: u16, texts: &[&str]) -> Paragraph { + let mut t = Table { + row_count: rows, + col_count: cols, + ..Default::default() + }; + for (i, text) in texts.iter().enumerate() { + let r = (i as u16) / cols; + let c = (i as u16) % cols; + t.cells.push(cell(r, c, text)); + } + let mut p = para(""); + p.controls.push(Control::Table(Box::new(t))); + p + } + + fn doc_with(paragraphs: Vec) -> Document { + Document { + sections: vec![Section { + paragraphs, + ..Default::default() + }], + ..Default::default() + } + } + + fn sel(src: &str, doc: &Document) -> Vec { + let s = parse(src).unwrap_or_else(|e| panic!("{}", e.render(src))); + select(&s, doc) + .unwrap_or_else(|e| panic!("{e}")) + .into_iter() + .map(|n| n.id.to_string()) + .collect() + } + + fn texts(src: &str, doc: &Document) -> Vec { + let s = parse(src).unwrap(); + select(&s, doc) + .unwrap() + .into_iter() + .filter_map(|n| n.node.text()) + .collect() + } + + #[test] + fn selects_paragraphs_in_document_order() { + let doc = doc_with(vec![para("가"), para("나"), para("다")]); + assert_eq!( + sel("para", &doc), + vec![ + "/section[0]/para[0]", + "/section[0]/para[1]", + "/section[0]/para[2]" + ] + ); + } + + #[test] + fn first_step_reaches_any_depth() { + // 셀 안 문단도 `para` 로 걸린다 — CSS 의 타입 선택자와 같은 규칙. + let doc = doc_with(vec![para("바깥"), table_para(1, 2, &["안1", "안2"])]); + let all = texts("para", &doc); + assert!(all.contains(&"바깥".to_string())); + assert!(all.contains(&"안1".to_string())); + } + + #[test] + fn child_combinator_does_not_cross_levels() { + let doc = doc_with(vec![para("바깥"), table_para(1, 2, &["안1", "안2"])]); + // 구역의 직계 문단만 — 셀 안 문단은 제외. + let direct = texts("section > para", &doc); + assert!(direct.contains(&"바깥".to_string())); + assert!(!direct.contains(&"안1".to_string())); + } + + #[test] + fn descendant_combinator_crosses_levels() { + let doc = doc_with(vec![table_para(1, 2, &["안1", "안2"])]); + let inner = texts("table para", &doc); + assert_eq!(inner, vec!["안1".to_string(), "안2".to_string()]); + } + + #[test] + fn cell_addressing_by_row_and_col() { + let doc = doc_with(vec![table_para(2, 2, &["A", "B", "C", "D"])]); + assert_eq!(texts("cell[row=1][col=0]", &doc), vec!["C".to_string()]); + } + + #[test] + fn sibling_combinators_respect_node_kind() { + let doc = doc_with(vec![para("가"), para("나"), para("다")]); + // `+` 는 바로 다음 형제 하나. + assert_eq!(texts("para[index=0] + para", &doc), vec!["나".to_string()]); + // `~` 는 이후 형제 전부. + assert_eq!( + texts("para[index=0] ~ para", &doc), + vec!["나".to_string(), "다".to_string()] + ); + } + + #[test] + fn positional_pseudos_count_over_the_result_set() { + let doc = doc_with(vec![ + table_para(1, 1, &["첫"]), + table_para(1, 1, &["둘"]), + table_para(1, 1, &["셋"]), + ]); + // 표 셋은 각각 다른 문단에 있으므로 형제 기준이라면 전부 `index=0` 이다. + // 결과 집합 기준이므로 `:last` 는 세 번째 표를 고른다. + assert_eq!(texts("table:last cell", &doc), vec!["셋".to_string()]); + assert_eq!(texts("table:nth(1) cell", &doc), vec!["둘".to_string()]); + assert_eq!(texts("table:first cell", &doc), vec!["첫".to_string()]); + } + + #[test] + fn negative_nth_counts_from_the_end() { + let doc = doc_with(vec![para("가"), para("나"), para("다")]); + assert_eq!(texts("para:nth(-1)", &doc), vec!["다".to_string()]); + assert_eq!(texts("para:nth(-3)", &doc), vec!["가".to_string()]); + // 범위를 벗어나면 빈 결과 — 패닉이 아니다. + assert!(texts("para:nth(-9)", &doc).is_empty()); + assert!(texts("para:nth(9)", &doc).is_empty()); + } + + #[test] + fn range_clamps_instead_of_failing() { + let doc = doc_with(vec![para("가"), para("나"), para("다")]); + assert_eq!( + texts("para:range(1..99)", &doc), + vec!["나".to_string(), "다".to_string()] + ); + assert_eq!(texts("para:range(0..1)", &doc), vec!["가".to_string()]); + assert!(texts("para:range(5..9)", &doc).is_empty()); + } + + #[test] + fn value_predicates_run_before_positional_ones() { + let doc = doc_with(vec![ + para_styled("가", 1), + para_styled("나", 2), + para_styled("다", 1), + ]); + // 스타일 1 인 문단들 중 마지막 = "다". 위치를 먼저 적용했다면 "다"를 + // 고른 뒤 스타일을 봐서 결과가 같겠지만, 스타일 2 로 물으면 갈린다. + assert_eq!(texts("para[styleId=2]:last", &doc), vec!["나".to_string()]); + } + + #[test] + fn contains_and_matches_use_visible_text() { + // 제어문자가 섞여 있어도 사람이 보는 글자로 걸려야 한다. + let doc = doc_with(vec![para("합\u{0003}계")]); + assert_eq!(texts(r#"para:contains("합계")"#, &doc).len(), 1); + assert_eq!(texts(r#"para:matches("합*")"#, &doc).len(), 1); + assert_eq!(texts(r#"para[text="합계"]"#, &doc).len(), 1); + } + + #[test] + fn empty_only_matches_nodes_that_have_text() { + let mut p = para(""); + p.controls.push(Control::Bookmark(Bookmark { + name: "표시".into(), + })); + let doc = doc_with(vec![p]); + // 빈 문단은 걸린다. + assert_eq!(sel("para:empty", &doc).len(), 1); + // 텍스트 개념이 없는 책갈피는 걸리지 않는다. + assert!(sel("bookmark:empty", &doc).is_empty()); + } + + #[test] + fn not_excludes_the_inner_result_set() { + let doc = doc_with(vec![para("가"), para(""), para("다")]); + assert_eq!( + texts("para:not(:empty)", &doc), + vec!["가".to_string(), "다".to_string()] + ); + } + + #[test] + fn has_matches_ancestors_of_the_inner_result() { + let doc = doc_with(vec![ + table_para(1, 1, &["합계"]), + table_para(1, 1, &["기타"]), + ]); + let hits = sel(r#"table:has(cell:contains("합계"))"#, &doc); + assert_eq!(hits.len(), 1); + assert!(hits[0].ends_with("/control[0]")); + assert!(hits[0].starts_with("/section[0]/para[0]")); + } + + #[test] + fn union_paths_are_merged_in_document_order_without_duplicates() { + let doc = doc_with(vec![para("가"), para("나")]); + // 두 가지가 겹쳐도 한 번만 나온다. + assert_eq!(sel("para, para[index=0]", &doc).len(), 2); + } + + #[test] + fn missing_attribute_never_matches_even_with_ne() { + let doc = doc_with(vec![table_para(1, 1, &["A"])]); + // 셀에 field_name 이 없다. `!=` 로도 걸리면 안 된다. + assert!(sel(r#"cell[name!="합계"]"#, &doc).is_empty()); + assert!(sel("cell[name]", &doc).is_empty()); + } + + #[test] + fn bare_boolean_attribute_means_true() { + let mut t = Table { + row_count: 1, + col_count: 1, + ..Default::default() + }; + let mut c = cell(0, 0, "제목"); + c.is_header = true; + t.cells.push(c); + t.cells.push(cell(0, 1, "값")); + let mut p = para(""); + p.controls.push(Control::Table(Box::new(t))); + let doc = doc_with(vec![p]); + assert_eq!(texts("cell[header]", &doc), vec!["제목".to_string()]); + assert_eq!(texts("cell[header=false]", &doc), vec!["값".to_string()]); + } + + #[test] + fn field_name_prefers_ctrl_data_and_ignores_empty() { + let mut p = para(""); + p.controls.push(Control::Field(Field { + field_type: FieldType::ClickHere, + command: "안내문".into(), + ctrl_data_name: Some("수급자성명".into()), + ..Default::default() + })); + p.controls.push(Control::Field(Field { + field_type: FieldType::ClickHere, + command: String::new(), + ctrl_data_name: None, + ..Default::default() + })); + let doc = doc_with(vec![p]); + assert_eq!(sel(r#"field[name="수급자성명"]"#, &doc).len(), 1); + // 이름이 빈 필드는 `[name]` 에 걸리지 않는다. + assert_eq!(sel("field[name]", &doc).len(), 1); + assert_eq!(sel(r#"field[type=clickHere]"#, &doc).len(), 2); + } + + #[test] + fn table_axis_and_control_kind_select_the_same_thing() { + let doc = doc_with(vec![table_para(1, 1, &["A"])]); + assert_eq!(sel("table", &doc), sel("control[kind=table]", &doc)); + } + + #[test] + fn runs_are_selectable_by_char_shape() { + let mut p = para("가나다라"); + p.char_shapes = vec![ + CharShapeRef { + start_pos: 0, + char_shape_id: 7, + }, + CharShapeRef { + start_pos: 2, + char_shape_id: 9, + }, + ]; + let doc = doc_with(vec![p]); + assert_eq!(texts("run[charShapeId=9]", &doc), vec!["다라".to_string()]); + } + + #[test] + fn node_limit_is_enforced_as_an_error_not_an_oom() { + let doc = doc_with((0..50).map(|i| para(&format!("문단{i}"))).collect()); + let s = parse("para").unwrap(); + let limits = EvalLimits { + max_nodes: 10, + ..Default::default() + }; + let err = select_with(&s, &doc, limits).unwrap_err(); + assert!(err.message.contains("노드가 너무 많다")); + } + + #[test] + fn result_limit_is_enforced() { + let doc = doc_with((0..20).map(|i| para(&format!("문단{i}"))).collect()); + let s = parse("para").unwrap(); + let limits = EvalLimits { + max_results: 5, + ..Default::default() + }; + let err = select_with(&s, &doc, limits).unwrap_err(); + assert!(err.message.contains("결과가 너무 많다")); + // 세는 것은 상한에 막히지 않는다. + assert_eq!(count(&s, &doc, limits).unwrap(), 20); + } + + #[test] + fn empty_document_selects_nothing_without_error() { + let doc = Document::default(); + assert!(sel("para", &doc).is_empty()); + assert!(sel("table cell", &doc).is_empty()); + } + + #[test] + fn index_attribute_stays_sibling_relative() { + // 위치 의사 선택자는 결과 집합 기준이지만 `index` 는 형제 기준이다. + // 두 기준이 같은 문법을 쓰면 어느 쪽인지 알 수 없게 된다. + let doc = doc_with(vec![table_para(1, 1, &["첫"]), table_para(1, 1, &["둘"])]); + // 표 둘 다 자기 문단의 첫 컨트롤이므로 index=0 이 둘 다 걸린다. + assert_eq!(sel("table[index=0]", &doc).len(), 2); + // 결과 집합 기준인 :first 는 하나만 고른다. + assert_eq!(sel("table:first", &doc).len(), 1); + } +} diff --git a/src/agent/dsel/glob.rs b/src/agent/dsel/glob.rs new file mode 100644 index 0000000000..181b8c3e66 --- /dev/null +++ b/src/agent/dsel/glob.rs @@ -0,0 +1,385 @@ +//! 글롭 일치기 — `~=` 와 `:matches(…)` 의 판정기. +//! +//! ## 왜 정규식이 아닌가 +//! +//! 두 가지다. +//! +//! 1. **의존성.** rhwp 는 정규식 크레이트를 쓰지 않는다. 선택자 하나 때문에 +//! 의존성을 늘리면 wasm 크기와 감사 표면이 함께 늘어난다. +//! 2. **정지성.** 역추적 정규식은 입력에 따라 지수 시간으로 터진다. 선택자는 +//! **문서에서 온 값**과 맞대어지는데, 문서는 신뢰 경계 바깥이다 +//! (`provenance::MAP` 이 같은 말을 한다). 신뢰할 수 없는 입력에 지수 시간 +//! 판정기를 붙이는 것은 DoS 를 스스로 심는 것이다. +//! +//! 그래서 문법을 글롭으로 좁히고, **역추적 지점이 `*` 하나뿐**인 고전 알고리즘을 +//! 쓴다. 최악 시간은 `O(패턴 × 입력)` 로 유계이며 지수 경로가 존재하지 않는다. +//! +//! ## 문법 +//! +//! | 표기 | 뜻 | +//! | --- | --- | +//! | `*` | 임의 길이(0 포함) | +//! | `?` | 임의의 한 글자 | +//! | `[abc]` | 나열된 글자 중 하나 | +//! | `[a-z]` | 범위 안의 한 글자 | +//! | `[!abc]` / `[^abc]` | 나열되지 **않은** 한 글자 | +//! | `\x` | `x` 를 글자 그대로 | +//! +//! 글자 단위는 `char` 다 — UTF-8 바이트가 아니다. `?` 하나가 한글 한 글자에 +//! 대응해야 사람이 쓴 패턴이 예상대로 동작한다. + +use super::error::SelectorError; + +/// 컴파일된 글롭 패턴. +/// +/// 매번 문자열을 다시 훑지 않으려고 조각으로 미리 쪼갠다. 같은 선택자를 여러 +/// 노드에 대해 평가하므로 컴파일 한 번 : 판정 N 번의 비율이 된다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Glob { + segs: Vec, + source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Seg { + /// `*` + Star, + /// `?` + One, + /// 글자 하나 그대로. + Lit(char), + /// 글자 집합. + Class { + negated: bool, + items: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ClassItem { + Single(char), + Range(char, char), +} + +impl ClassItem { + fn contains(&self, c: char) -> bool { + match self { + ClassItem::Single(x) => *x == c, + ClassItem::Range(lo, hi) => *lo <= c && c <= *hi, + } + } +} + +impl Glob { + /// 패턴을 컴파일한다. + /// + /// `offset` 은 원문에서 패턴 문자열이 시작한 위치 — 오류 오프셋을 선택자 + /// 전체 기준으로 돌려주려고 받는다. 패턴 내부 기준으로 돌려주면 캐럿이 + /// 엉뚱한 곳에 선다. + pub fn compile(pattern: &str, offset: usize) -> Result { + let chars: Vec = pattern.chars().collect(); + let mut segs = Vec::new(); + let mut i = 0usize; + + while i < chars.len() { + match chars[i] { + '*' => { + // 연속 `*` 는 하나와 같다. 접어 두지 않으면 `***` 가 역추적 + // 지점을 셋 만들어 최악 시간이 패턴 길이에 곱해진다. + if segs.last() != Some(&Seg::Star) { + segs.push(Seg::Star); + } + i += 1; + } + '?' => { + segs.push(Seg::One); + i += 1; + } + '\\' => { + let c = chars.get(i + 1).copied().ok_or_else(|| { + SelectorError::resolve(offset + i, "역슬래시로 끝나는 글롭 패턴") + .hinting("역슬래시 자체는 `\\\\` 로 적는다") + })?; + segs.push(Seg::Lit(c)); + i += 2; + } + '[' => { + let (seg, next) = compile_class(&chars, i, offset)?; + segs.push(seg); + i = next; + } + ']' => { + return Err(SelectorError::resolve(offset + i, "짝 없는 `]`") + .hinting("글자 그대로 쓰려면 `\\]` 로 적는다")); + } + c => { + segs.push(Seg::Lit(c)); + i += 1; + } + } + } + + Ok(Glob { + segs, + source: pattern.to_string(), + }) + } + + /// 문자열 전체가 패턴에 맞는가. + /// + /// 부분 일치가 아니라 **전체 일치**다. 부분 일치가 필요하면 패턴 양끝에 `*` 를 + /// 붙인다 — 기본을 부분 일치로 두면 `~="합계"` 가 "합계표"에도 맞아서, 정확히 + /// 맞는 것만 고르려면 매번 앵커를 적어야 한다. 정확 일치가 더 흔한 의도다. + pub fn is_match(&self, text: &str) -> bool { + let input: Vec = text.chars().collect(); + self.match_chars(&input) + } + + /// 원문 패턴. + pub fn source(&self) -> &str { + &self.source + } + + /// 이 패턴이 모든 문자열에 맞는가 (`*`, `**` …). + /// + /// 평가기가 술어를 통째로 건너뛸 수 있는지 판단하는 데 쓴다. + pub fn is_universal(&self) -> bool { + self.segs.iter().all(|s| matches!(s, Seg::Star)) + } + + /// 고전 선형 글롭 일치 — 역추적 지점은 마지막 `*` 하나뿐. + /// + /// `star` 는 마지막으로 본 `*` 의 세그먼트 위치, `mark` 는 그때 입력 위치다. + /// 불일치가 나면 그 `*` 가 한 글자 더 먹은 것으로 치고 재개한다. 재귀가 없으므로 + /// 스택 오버플로 경로도 없다 — 파서와 달리 여기는 깊이 상한이 필요 없다. + fn match_chars(&self, input: &[char]) -> bool { + let mut i = 0usize; // 입력 위치 + let mut j = 0usize; // 세그먼트 위치 + let mut star: Option = None; + let mut mark = 0usize; + + while i < input.len() { + match self.segs.get(j) { + Some(Seg::Star) => { + star = Some(j); + mark = i; + j += 1; + } + Some(seg) if seg_matches(seg, input[i]) => { + i += 1; + j += 1; + } + _ => match star { + Some(s) => { + // `*` 가 한 글자 더 먹는다. + j = s + 1; + mark += 1; + i = mark; + } + None => return false, + }, + } + } + + // 남은 세그먼트는 전부 `*` 여야 한다. + self.segs[j.min(self.segs.len())..] + .iter() + .all(|s| matches!(s, Seg::Star)) + } +} + +fn seg_matches(seg: &Seg, c: char) -> bool { + match seg { + Seg::One => true, + Seg::Lit(x) => *x == c, + Seg::Class { negated, items } => { + let hit = items.iter().any(|it| it.contains(c)); + hit != *negated + } + Seg::Star => unreachable!("호출부가 Star 를 먼저 처리한다"), + } +} + +/// `[` 에서 시작하는 글자 집합을 컴파일한다. +fn compile_class( + chars: &[char], + start: usize, + offset: usize, +) -> Result<(Seg, usize), SelectorError> { + let mut i = start + 1; + let negated = matches!(chars.get(i), Some('!') | Some('^')); + if negated { + i += 1; + } + + let mut items = Vec::new(); + // 첫 글자가 `]` 면 글자 그대로 — POSIX 관습이고, 이게 없으면 `]` 를 집합에 + // 넣을 방법이 이스케이프뿐이 된다. + let mut first = true; + + while i < chars.len() { + let c = chars[i]; + // `first` 가 거짓이면 앞선 반복이 반드시 항목 하나를 밀어 넣었으므로 + // 여기서 `items` 가 비는 경우는 없다 — 빈 집합 분기를 따로 두지 않는 이유다. + if c == ']' && !first { + debug_assert!(!items.is_empty(), "첫 글자가 아닌 `]` 앞에는 항목이 있다"); + return Ok((Seg::Class { negated, items }, i + 1)); + } + first = false; + + let lo = if c == '\\' { + i += 1; + *chars + .get(i) + .ok_or_else(|| SelectorError::resolve(offset + i, "역슬래시로 끝나는 글자 집합"))? + } else { + c + }; + + // 범위인가 — `a-z`. 뒤가 `]` 면 `-` 는 글자 그대로다. + if chars.get(i + 1) == Some(&'-') && chars.get(i + 2).is_some_and(|c| *c != ']') { + let mut k = i + 2; + let hi = if chars[k] == '\\' { + k += 1; + *chars.get(k).ok_or_else(|| { + SelectorError::resolve(offset + k, "역슬래시로 끝나는 글자 집합") + })? + } else { + chars[k] + }; + if hi < lo { + return Err(SelectorError::resolve( + offset + i, + format!("뒤집힌 글자 범위 `{lo}-{hi}`"), + ) + .hinting("작은 글자를 앞에 적는다")); + } + items.push(ClassItem::Range(lo, hi)); + i = k + 1; + continue; + } + + items.push(ClassItem::Single(lo)); + i += 1; + } + + Err( + SelectorError::resolve(offset + start, "닫히지 않은 글자 집합 `[`") + .hinting("대괄호를 글자로 쓰려면 `\\[` 로 적는다"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(pattern: &str, text: &str) -> bool { + Glob::compile(pattern, 0).unwrap().is_match(text) + } + + #[test] + fn literal_match_is_whole_string() { + assert!(m("합계", "합계")); + // 부분 일치가 아니다 — 앵커 없이 접두만 맞는 것은 불일치. + assert!(!m("합계", "합계표")); + assert!(m("합계*", "합계표")); + } + + #[test] + fn question_mark_counts_chars_not_bytes() { + // 한글 한 글자는 UTF-8 3바이트. `?` 하나로 맞아야 한다. + assert!(m("?계", "합계")); + assert!(!m("??계", "합계")); + } + + #[test] + fn star_matches_empty() { + assert!(m("*", "")); + assert!(m("a*b", "ab")); + } + + #[test] + fn consecutive_stars_collapse() { + let g = Glob::compile("a***b", 0).unwrap(); + assert_eq!(g.segs.iter().filter(|s| **s == Seg::Star).count(), 1); + assert!(g.is_match("axxxb")); + } + + #[test] + fn classes_ranges_and_negation() { + assert!(m("[abc]", "b")); + assert!(!m("[abc]", "d")); + assert!(m("[a-z]", "q")); + assert!(!m("[a-z]", "Q")); + assert!(m("[!abc]", "d")); + assert!(!m("[!abc]", "a")); + assert!(m("[^abc]", "d")); + } + + #[test] + fn class_can_contain_closing_bracket_first() { + assert!(m("[]a]", "]")); + assert!(m("[]a]", "a")); + } + + #[test] + fn trailing_hyphen_in_class_is_literal() { + assert!(m("[a-]", "-")); + assert!(m("[a-]", "a")); + } + + #[test] + fn escapes_disable_metacharacters() { + assert!(m(r"\*", "*")); + assert!(!m(r"\*", "x")); + assert!(m(r"\[a\]", "[a]")); + } + + #[test] + fn reversed_range_is_rejected() { + let err = Glob::compile("[z-a]", 0).unwrap_err(); + assert!(err.message.contains("뒤집힌")); + } + + #[test] + fn unclosed_class_is_rejected_with_a_hint() { + let err = Glob::compile("[abc", 0).unwrap_err(); + assert!(err.hint.unwrap().contains(r"\[")); + } + + #[test] + fn bare_bracket_pair_is_unclosed_not_empty() { + // POSIX 관습대로 `[` 바로 뒤의 `]` 는 글자다. 따라서 `[]` 는 "빈 집합"이 + // 아니라 "아직 닫히지 않은 집합"이며, 진단도 그렇게 말해야 한다. + let err = Glob::compile("[]", 0).unwrap_err(); + assert!(err.message.contains("닫히지 않은"), "{}", err.message); + } + + #[test] + fn error_offsets_are_relative_to_the_whole_selector() { + // 패턴이 선택자의 10번째 문자에서 시작했다면 오류도 그 기준이어야 한다. + let err = Glob::compile("[abc", 10).unwrap_err(); + assert_eq!(err.offset, 10); + } + + #[test] + fn pathological_pattern_terminates_quickly() { + // 역추적 정규식이라면 지수 시간이 되는 모양. 여기서는 유계다. + let pattern = "*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*b"; + let text = "a".repeat(2000); + // 판정 자체가 끝나는 것이 요점이다 — 끝나지 않으면 테스트가 시간 초과로 죽는다. + assert!(!m(pattern, &text)); + } + + #[test] + fn universal_pattern_is_detected() { + assert!(Glob::compile("*", 0).unwrap().is_universal()); + assert!(Glob::compile("**", 0).unwrap().is_universal()); + assert!(!Glob::compile("*a*", 0).unwrap().is_universal()); + } + + #[test] + fn source_is_preserved_for_diagnostics() { + assert_eq!(Glob::compile("a*b", 0).unwrap().source(), "a*b"); + } +} diff --git a/src/agent/dsel/lex.rs b/src/agent/dsel/lex.rs new file mode 100644 index 0000000000..fdfc086519 --- /dev/null +++ b/src/agent/dsel/lex.rs @@ -0,0 +1,402 @@ +//! DSEL 렉서 — 최장 일치, 문자 오프셋, 이스케이프 해제. +//! +//! ## 문자 단위로 도는 이유 +//! +//! 선택자에는 한글이 그대로 들어온다 (`para[style="개요 1"]`, `field[name="수급자성명"]`). +//! 바이트 인덱스로 돌면 오프셋이 글자 경계와 어긋나 캐럿이 글자 중간을 가리키고, +//! 슬라이싱은 `byte index is not a char boundary` 로 패닉한다. 그래서 입력을 한 번 +//! `Vec` 로 펼치고 전 구간을 문자 인덱스로 다룬다. 선택자는 길어야 수백 자라 +//! 이 사본의 비용은 무시할 수 있고, 얻는 것은 "패닉 가능 경로가 없다"는 성질이다. +//! +//! ## 최장 일치를 손으로 적는 이유 +//! +//! 기호가 열 몇 개뿐이고 접두 충돌이 `>`·`>=`, `*`·`*=`, `~`·`~=` 세 쌍뿐이다. +//! 표를 만들어 일반화하면 표를 읽어야 규칙을 알 수 있게 되는데, 규칙이 세 줄이면 +//! 세 줄로 적는 편이 감사 가능하다. + +use super::error::SelectorError; +use super::token::{Tok, Token}; + +/// 렉싱 결과 — 토큰 열과 입력 문자 길이(EOF 오프셋 계산용). +#[derive(Debug, Clone)] +pub struct Lexed { + pub tokens: Vec, + /// 입력의 문자 개수. 파서가 "입력 끝"의 오프셋으로 쓴다. + pub char_len: usize, +} + +/// 선택자 문자열을 토큰 열로 쪼갠다. +/// +/// 공백은 [`Tok::Ws`] 로 **남는다**(합쳐서 한 토큰). 버리지 않는 이유는 +/// `token` 모듈 문서에 적었다. +pub fn lex(input: &str) -> Result { + let chars: Vec = input.chars().collect(); + let mut tokens = Vec::new(); + let mut i = 0usize; + + while i < chars.len() { + let start = i; + let c = chars[i]; + + // 공백 덩어리 — 여러 칸이어도 결합자 하나다. + if c.is_whitespace() { + while i < chars.len() && chars[i].is_whitespace() { + i += 1; + } + tokens.push(Token::new(Tok::Ws, start)); + continue; + } + + // 따옴표 문자열. + if c == '"' || c == '\'' { + let (value, next) = lex_string(&chars, i, c)?; + tokens.push(Token::new(Tok::Str(value), start)); + i = next; + continue; + } + + // 숫자. 부호는 여기서 먹는다 — `:nth(-1)` 을 파서가 단항 마이너스로 + // 처리하게 하면 `[level>-1]` 같은 자리에서 `>-` 를 연산자로 오인할 여지가 + // 생긴다. 부호 있는 정수를 하나의 토큰으로 확정하는 편이 문법이 단순하다. + if c.is_ascii_digit() + || (c == '-' && matches!(chars.get(i + 1), Some(d) if d.is_ascii_digit())) + { + let (value, next) = lex_int(&chars, i)?; + tokens.push(Token::new(Tok::Int(value), start)); + i = next; + continue; + } + + // 식별자 — 유니코드 문자로 시작, 이어서 문자·숫자·`_`·`-`. + // + // `-` 를 이어붙이는 것이 `a-1` 을 `a`,`-1` 로 읽지 않게 만든다. 뺄셈이 + // 없는 언어라 이 선택에 모호함이 없다. + if is_ident_start(c) { + let mut end = i + 1; + while end < chars.len() && is_ident_continue(chars[end]) { + end += 1; + } + let text: String = chars[i..end].iter().collect(); + tokens.push(Token::new(Tok::Ident(text), start)); + i = end; + continue; + } + + // 기호 — 두 글자 먼저, 그 다음 한 글자. + let two = if i + 1 < chars.len() { + Some((c, chars[i + 1])) + } else { + None + }; + let (tok, width) = match two { + Some(('>', '=')) => (Tok::Ge, 2), + Some(('<', '=')) => (Tok::Le, 2), + Some(('!', '=')) => (Tok::Ne, 2), + Some(('^', '=')) => (Tok::Prefix, 2), + Some(('$', '=')) => (Tok::Suffix, 2), + Some(('*', '=')) => (Tok::Substr, 2), + Some(('~', '=')) => (Tok::Glob, 2), + Some(('.', '.')) => (Tok::DotDot, 2), + _ => match c { + '*' => (Tok::Star, 1), + '[' => (Tok::LBracket, 1), + ']' => (Tok::RBracket, 1), + '(' => (Tok::LParen, 1), + ')' => (Tok::RParen, 1), + ':' => (Tok::Colon, 1), + ',' => (Tok::Comma, 1), + '>' => (Tok::Gt, 1), + '<' => (Tok::Lt, 1), + '=' => (Tok::Eq, 1), + '+' => (Tok::Plus, 1), + '~' => (Tok::Tilde, 1), + '!' => { + return Err(SelectorError::lex(start, "`!` 뒤에는 `=` 만 올 수 있다") + .expecting(["!="]) + .hinting("부정은 `:not(...)` 으로 쓴다")); + } + '.' => { + return Err(SelectorError::lex(start, "`.` 하나는 뜻이 없다") + .expecting([".."]) + .hinting("범위는 `:nth(1..3)`, 자손은 공백으로 쓴다")); + } + '#' => { + return Err(SelectorError::lex(start, "`#` 문법은 없다") + .hinting("이름 지목은 `[name=\"…\"]` 으로 쓴다")); + } + other => { + return Err(SelectorError::lex( + start, + format!("선택자에 쓸 수 없는 문자 `{other}`"), + )); + } + }, + }; + tokens.push(Token::new(tok, start)); + i += width; + } + + Ok(Lexed { + tokens, + char_len: chars.len(), + }) +} + +/// 식별자 첫 글자로 쓸 수 있나. +/// +/// `is_alphabetic` 이라 한글·한자도 통과한다. 축 이름은 ASCII 로만 정의돼 있지만 +/// 렉서가 미리 막지는 않는다 — 막으면 오류가 "쓸 수 없는 문자"로 나와서 정작 +/// "그런 축은 없다"는 진짜 원인을 가린다. 미지의 축은 해석 단계에서 후보와 함께 +/// 거절하는 편이 훨씬 쓸모 있다. +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' +} + +/// 식별자 이어짐. +fn is_ident_continue(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '-' +} + +/// 정수 하나를 읽는다. +/// +/// 오버플로를 `i64` 범위에서 **명시적으로** 거절한다. `parse::()` 의 오류를 +/// 그대로 흘리면 메시지가 영어 `number too large to fit in target type` 로 나가 +/// 봉투의 한국어 진단 규약과 어긋난다. +fn lex_int(chars: &[char], start: usize) -> Result<(i64, usize), SelectorError> { + let mut i = start; + if chars[i] == '-' { + i += 1; + } + let digits_from = i; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + debug_assert!(i > digits_from, "호출부가 첫 숫자를 확인하고 부른다"); + let text: String = chars[start..i].iter().collect(); + let value = text + .parse::() + .map_err(|_| SelectorError::lex(start, format!("정수 범위를 벗어난 값 `{text}`")))?; + Ok((value, i)) +} + +/// 따옴표 문자열 하나를 읽고 이스케이프를 푼다. +/// +/// 지원 이스케이프는 `\\ \" \' \n \t \r \0` 와 `\uXXXX` 뿐이다. 목록을 좁게 두는 +/// 이유는 선택자가 감사 대상 문자열이기 때문이다 — 8진·16진 바이트 이스케이프를 +/// 열어 두면 같은 선택자를 여러 방식으로 적을 수 있고, 그러면 "이 선택자와 저 +/// 선택자가 같은가"를 문자열 비교로 판정할 수 없다. +fn lex_string(chars: &[char], start: usize, quote: char) -> Result<(String, usize), SelectorError> { + let mut out = String::new(); + let mut i = start + 1; + + while i < chars.len() { + let c = chars[i]; + if c == quote { + return Ok((out, i + 1)); + } + if c != '\\' { + out.push(c); + i += 1; + continue; + } + + // 이스케이프. + let esc = chars.get(i + 1).copied().ok_or_else(|| { + SelectorError::lex(i, "역슬래시로 끝나는 문자열") + .hinting("역슬래시 자체는 `\\\\` 로 적는다") + })?; + match esc { + '\\' => out.push('\\'), + '"' => out.push('"'), + '\'' => out.push('\''), + 'n' => out.push('\n'), + 't' => out.push('\t'), + 'r' => out.push('\r'), + '0' => out.push('\0'), + 'u' => { + let (ch, next) = lex_unicode_escape(chars, i)?; + out.push(ch); + i = next; + continue; + } + other => { + return Err( + SelectorError::lex(i, format!("알 수 없는 이스케이프 `\\{other}`")) + .expecting(["\\\\", "\\\"", "\\'", "\\n", "\\t", "\\r", "\\0", "\\uXXXX"]), + ); + } + } + i += 2; + } + + Err( + SelectorError::lex(start, format!("닫히지 않은 문자열 (`{quote}` 로 시작)")) + .hinting("문자열 안의 따옴표는 역슬래시로 감싼다"), + ) +} + +/// `\uXXXX` 를 읽는다. 서로게이트 쌍은 `😀` 형태로 이어 붙인다. +/// +/// 서로게이트를 받는 이유: JSON 문자열에서 그대로 옮겨 온 선택자가 BMP 밖 문자를 +/// 그 형태로 싣는다. 앞쪽 서로게이트만 오면 유효한 `char` 가 아니므로 여기서 +/// 거절해야 하고, 거절하지 않으면 `char::from_u32` 가 `None` 을 주는 자리에서 +/// 원인을 알 수 없는 오류가 난다. +fn lex_unicode_escape(chars: &[char], at: usize) -> Result<(char, usize), SelectorError> { + let first = read_hex4(chars, at)?; + let mut next = at + 6; // `\uXXXX` + + if (0xD800..0xDC00).contains(&first) { + // 앞쪽 서로게이트 — 뒤쪽이 반드시 따라와야 한다. + let has_pair = chars.get(next) == Some(&'\\') && chars.get(next + 1) == Some(&'u'); + if !has_pair { + return Err(SelectorError::lex(at, "짝 없는 상위 서로게이트") + .hinting("BMP 밖 문자는 `\\uD83D\\uDE00` 처럼 두 개를 이어 적는다")); + } + let second = read_hex4(chars, next)?; + if !(0xDC00..0xE000).contains(&second) { + return Err(SelectorError::lex(next, "하위 서로게이트가 아니다")); + } + let combined = 0x1_0000 + ((first - 0xD800) << 10) + (second - 0xDC00); + next += 6; + let ch = char::from_u32(combined) + .ok_or_else(|| SelectorError::lex(at, "유효하지 않은 코드포인트"))?; + return Ok((ch, next)); + } + + if (0xDC00..0xE000).contains(&first) { + return Err(SelectorError::lex(at, "짝 없는 하위 서로게이트")); + } + + let ch = + char::from_u32(first).ok_or_else(|| SelectorError::lex(at, "유효하지 않은 코드포인트"))?; + Ok((ch, next)) +} + +/// `at` 위치의 `\u` 다음 네 자리 16진수를 읽는다. +fn read_hex4(chars: &[char], at: usize) -> Result { + let mut value = 0u32; + for k in 0..4 { + let c = chars.get(at + 2 + k).copied().ok_or_else(|| { + SelectorError::lex(at, "`\\u` 뒤 16진수 네 자리가 모자라다").expecting(["\\uXXXX"]) + })?; + let digit = c.to_digit(16).ok_or_else(|| { + SelectorError::lex(at + 2 + k, format!("16진수가 아닌 문자 `{c}`")) + .expecting(["0-9", "a-f", "A-F"]) + })?; + value = value * 16 + digit; + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(input: &str) -> Vec { + lex(input) + .unwrap() + .tokens + .into_iter() + .map(|t| t.tok) + .collect() + } + + #[test] + fn whitespace_survives_as_a_single_token() { + // 자손 결합자가 공백이므로 공백이 사라지면 문법이 무너진다. + assert_eq!( + kinds("a b"), + vec![Tok::Ident("a".into()), Tok::Ws, Tok::Ident("b".into())] + ); + } + + #[test] + fn longest_match_wins_on_operator_prefixes() { + assert_eq!(kinds(">="), vec![Tok::Ge]); + assert_eq!(kinds(">"), vec![Tok::Gt]); + assert_eq!(kinds("*="), vec![Tok::Substr]); + assert_eq!(kinds("*"), vec![Tok::Star]); + assert_eq!(kinds("~="), vec![Tok::Glob]); + assert_eq!(kinds("~"), vec![Tok::Tilde]); + assert_eq!(kinds(".."), vec![Tok::DotDot]); + } + + #[test] + fn korean_identifiers_and_strings_keep_char_offsets() { + let out = lex("표[제목=\"합계\"]").unwrap(); + // 첫 토큰은 0, `[` 는 문자 1 — 바이트로 세면 3 이 되어 캐럿이 어긋난다. + assert_eq!(out.tokens[0].offset, 0); + assert_eq!(out.tokens[1].offset, 1); + assert_eq!(out.tokens[1].tok, Tok::LBracket); + } + + #[test] + fn negative_int_is_one_token() { + assert_eq!(kinds("-1"), vec![Tok::Int(-1)]); + // 비교 연산자 뒤의 음수도 갈라지지 않는다. + assert_eq!(kinds(">-1"), vec![Tok::Gt, Tok::Int(-1)]); + } + + #[test] + fn hyphen_binds_into_identifiers() { + assert_eq!(kinds("page-break"), vec![Tok::Ident("page-break".into())]); + } + + #[test] + fn string_escapes_are_unescaped_once() { + assert_eq!(kinds(r#""a\"b""#), vec![Tok::Str("a\"b".into())]); + assert_eq!(kinds(r#""tab\there""#), vec![Tok::Str("tab\there".into())]); + assert_eq!(kinds(r#""A""#), vec![Tok::Str("A".into())]); + } + + #[test] + fn surrogate_pairs_combine() { + assert_eq!(kinds(r#""😀""#), vec![Tok::Str("😀".into())]); + } + + #[test] + fn lone_surrogate_is_rejected_with_a_hint() { + let err = lex(r#""\uD83D""#).unwrap_err(); + assert!(err.message.contains("서로게이트")); + assert!(err.hint.is_some()); + } + + #[test] + fn unterminated_string_points_at_the_opening_quote() { + let err = lex("para[style=\"열림").unwrap_err(); + // 시작 따옴표 위치를 가리켜야 어디부터 닫아야 할지 알 수 있다. + assert_eq!(err.offset, 11); + } + + #[test] + fn unknown_escape_lists_the_allowed_set() { + let err = lex(r#""\q""#).unwrap_err(); + assert!(err.expected.iter().any(|e| e == "\\uXXXX")); + } + + #[test] + fn hash_and_dot_redirect_to_real_syntax() { + let hash = lex("#T1").unwrap_err(); + assert!(hash.hint.unwrap().contains("[name=")); + let dot = lex("a.b").unwrap_err(); + assert!(dot.hint.unwrap().contains(":nth")); + } + + #[test] + fn bang_alone_points_at_not() { + let err = lex("a!b").unwrap_err(); + assert!(err.hint.unwrap().contains(":not")); + } + + #[test] + fn int_overflow_is_a_korean_diagnostic() { + let err = lex("99999999999999999999").unwrap_err(); + assert!(err.message.contains("정수 범위")); + } + + #[test] + fn char_len_is_reported_for_eof_offsets() { + // 한글 3자 = 바이트 9. 파서가 EOF 오프셋으로 쓸 값은 3 이어야 한다. + assert_eq!(lex("문단표").unwrap().char_len, 3); + } +} diff --git a/src/agent/dsel/mod.rs b/src/agent/dsel/mod.rs new file mode 100644 index 0000000000..aa894001ef --- /dev/null +++ b/src/agent/dsel/mod.rs @@ -0,0 +1,120 @@ +//! DSEL — 문서 선택자 언어. +//! +//! ## 무엇을 푸는가 +//! +//! rhwp 의 편집 명령 여섯 개는 각자 다른 방식으로 대상을 지목한다 — 셀은 +//! `--table/--row/--col`, 필드는 `--data 이름=값[k]`, 치환은 `--find/--occurrence`. +//! 지목 문법이 명령마다 다르므로 "3절 두 번째 표의 마지막 행"처럼 명령이 미리 +//! 뚫어 두지 않은 대상은 **표현할 방법 자체가 없다**. DSEL 은 그 지목을 명령에서 +//! 분리해 하나의 값으로 만든다. +//! +//! ```text +//! section:nth(2) > table:last cell[row=-1] +//! └──────┬─────┘ └───┬────┘ └────┬─────┘ +//! 구역 지목 표 지목 셀 조건 +//! ``` +//! +//! ## CSS 를 닮게 만든 이유 +//! +//! 새 문법을 발명하면 그 문법을 배우는 비용이 채택을 막는다. 선택자를 쓰는 쪽은 +//! 대부분 언어모델이고, 모델은 CSS 선택자를 이미 안다. 결합자(` `, `>`, `+`, `~`), +//! 속성 술어(`[k=v]`), 의사 선택자(`:first`)를 같은 뜻으로 두면 문법 학습이 +//! 사실상 0 이 된다. 반대로 **CSS 에 있지만 여기 없는 것**(`#id`, `.class`, +//! `::before`)은 문서 모델에 대응물이 없어서 뺐다 — 있는 척하면 그게 더 나쁘다. +//! +//! ## 문법 요약 +//! +//! ```text +//! selector := path ("," path)* +//! path := step (combinator step)* +//! comb := " " (자손) | ">" (직계) | "+" (다음 형제) | "~" (이후 형제) +//! step := axis predicate* +//! axis := section|para|run|control|table|cell|picture|equation +//! | field|footnote|endnote|header|footer|bookmark|hyperlink|shape|* +//! pred := "[" name (op value)? "]" | ":" pseudo ("(" arg ")")? +//! op := "=" | "!=" | ">" | "<" | ">=" | "<=" | "^=" | "$=" | "*=" | "~=" +//! pseudo := first|last|empty|nth(n)|range(a..b)|contains(s)|matches(s)|not(sel)|has(sel) +//! ``` +//! +//! ## 신뢰 경계 +//! +//! 선택자는 **문서 밖에서** 온다(사람·계획서·모델). 선택자가 맞대는 값은 +//! **문서 안에서** 온다. 둘을 섞지 않는 것이 이 모듈의 규약이다 — 문서에서 읽은 +//! 문자열이 선택자 문법으로 재해석되는 경로는 존재하지 않는다. 그래서 문서에 +//! `para:has(...)` 라고 적혀 있어도 그건 그냥 글자다(`provenance` 가 말하는 +//! "문서 파생 = 데이터, 지시 아님"과 같은 원칙). +//! +//! ## 정지성 +//! +//! 파싱은 상한(길이·중첩·스텝·술어)으로 유계이고, 글롭 판정은 역추적 지점이 +//! `*` 하나뿐이라 지수 경로가 없다. 손상·적대적 입력으로 파서나 판정기를 멈추게 +//! 만드는 경로는 설계상 존재하지 않는다. + +mod ast; +mod error; +mod eval; +mod glob; +mod lex; +mod node; +mod parse; +mod token; + +pub use ast::{ + AttrDef, AttrPred, AttrType, Axis, AxisKind, CmpOp, Combinator, Literal, Path, Pred, Pseudo, + PseudoArity, PseudoDef, Selector, Step, AXIS_NAMES, COMMON_ATTRS, PSEUDO_DEFS, +}; +pub use error::{SelectorError, SelectorErrorKind}; +pub use eval::{count, select, select_with, EvalLimits}; +pub use glob::Glob; +pub use node::{ + control_axis, control_kind_name, paragraphs_of_control, runs_of, Node, NodeId, NodeRef, + NodeStep, PathStack, RunView, +}; +pub use parse::{parse, MAX_NESTING, MAX_PATHS, MAX_PREDS, MAX_SOURCE_CHARS, MAX_STEPS}; + +/// 토큰 표면 — 문법 강조·편집기 지원이 쓴다. 커널 내부 소비자는 없다. +pub use token::{Tok, Token}; + +/// 렉싱만 수행한다 — 편집기가 문법 강조에 쓴다. +/// +/// 파싱까지 하지 않는 이유: 편집 중인 선택자는 대개 문법적으로 미완성이라 +/// (`para[te` 상태) 파싱은 실패하지만 강조는 되어야 한다. +pub fn tokenize(source: &str) -> Result, SelectorError> { + lex::lex(source).map(|l| l.tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_surface_parses_a_realistic_selector() { + let sel = parse("section:nth(2) > table:last cell[row=-1]").unwrap(); + assert_eq!(sel.paths.len(), 1); + assert_eq!(sel.paths[0].steps.len(), 3); + assert_eq!(sel.result_axes(), vec![Axis::Kind(AxisKind::Cell)]); + } + + #[test] + fn tokenize_survives_an_incomplete_selector() { + // 파싱은 실패해도 렉싱은 되어야 편집기 강조가 산다. + assert!(parse("para[te").is_err()); + assert!(tokenize("para[te").is_ok()); + } + + #[test] + fn document_derived_text_is_never_reinterpreted_as_syntax() { + // 문서에 선택자처럼 생긴 글자가 있어도 값으로만 쓰인다. 이 테스트는 + // 값 자리에 들어간 문법 문자열이 중첩 선택자로 파싱되지 않음을 고정한다. + let sel = parse(r#"para[text="para:has(table)"]"#).unwrap(); + match &sel.paths[0].steps[0].preds[0] { + Pred::Attr(a) => assert_eq!( + a.compare, + Some((CmpOp::Eq, Literal::Str("para:has(table)".into()))) + ), + other => panic!("{other:?}"), + } + // 술어는 하나뿐이다 — 값 안의 `:has` 가 술어로 새지 않았다. + assert_eq!(sel.paths[0].steps[0].preds.len(), 1); + } +} diff --git a/src/agent/dsel/node.rs b/src/agent/dsel/node.rs new file mode 100644 index 0000000000..4eae1b7098 --- /dev/null +++ b/src/agent/dsel/node.rs @@ -0,0 +1,617 @@ +//! 노드 주소와 노드 뷰 — 선택자가 고른 "어디"를 값으로 만든다. +//! +//! ## 왜 주소가 따로 필요한가 +//! +//! 선택자의 결과는 `&Paragraph` 같은 참조로도 표현할 수 있다. 하지만 참조는 +//! **문서를 고치는 순간 죽는다**. 커널의 요점은 고르고 → 고치고 → 확인하는 +//! 것이므로, 고른 결과는 편집을 사이에 두고 살아남는 표현이어야 한다. 그래서 +//! 선택 결과는 [`NodeId`] — 뿌리에서 내려오는 **구조 경로**다. +//! +//! ```text +//! /section[0]/para[3]/control[0]/cell[5]/para[0] +//! └──┬───┘ └──┬──┘ └───┬────┘ └──┬──┘ └──┬──┘ +//! 구역 문단 표(컨트롤) 셀 셀 안 문단 +//! ``` +//! +//! 이 경로는 사람이 읽을 수 있고, JSON 에 그대로 실리고, 파일을 다시 열어도 같은 +//! 곳을 가리킨다(문서가 그대로라면). 편집으로 앞 형제가 사라지면 경로가 다른 것을 +//! 가리키게 되는데 — 그 문제를 푸는 것이 앵커 층이고, 앵커가 무엇을 고정할지 +//! 알려면 먼저 이 주소가 있어야 한다. +//! +//! ## 순번의 뜻 +//! +//! `index` 는 **같은 종류의 형제 중** 순번이다. 한 문단이 컨트롤 셋과 구간 둘을 +//! 가지면 컨트롤은 0·1·2, 구간은 0·1 로 각각 센다. 섞어서 세면 +//! `control[index=1]` 이 "두 번째 컨트롤"이 아니라 "두 번째 자식"이 되어, 문단에 +//! 글자 구간이 하나 늘 때마다 뜻이 바뀐다. +//! +//! ## 구간(run)은 어디서 오나 +//! +//! `Paragraph::char_shapes` 가 나눈다. 글자 모양 변경점이 곧 구간 경계다. +//! `char_shapes` 가 비어 있으면 구간은 **0 개**다 — 텍스트가 있어도 그렇다. +//! 비었을 때 "전체를 덮는 구간 하나"를 지어내면 존재하지 않는 글자 모양 ID 0 을 +//! 사실인 것처럼 싣게 된다. 없는 것은 없다고 하는 편이 정확하다. + +use std::fmt; + +use crate::model::control::Control; +use crate::model::document::Section; +use crate::model::paragraph::Paragraph; +use crate::model::table::Cell; + +use super::ast::{Axis, AxisKind}; + +/// 트리 경로의 한 마디. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum NodeStep { + Section(u32), + Para(u32), + Control(u32), + Cell(u32), + Run(u32), +} + +impl NodeStep { + /// 경로 표기에 쓰는 이름. + pub const fn axis_name(self) -> &'static str { + match self { + NodeStep::Section(_) => "section", + NodeStep::Para(_) => "para", + NodeStep::Control(_) => "control", + NodeStep::Cell(_) => "cell", + NodeStep::Run(_) => "run", + } + } + + /// 마디 종류의 구분 번호 — 형제 판정에 쓴다. + /// + /// 한 문단은 컨트롤과 구간을 함께 갖는다. `+`(다음 형제)가 종류를 보지 않으면 + /// 컨트롤 다음에 오는 구간이 "다음 형제"가 되어, 글자 모양이 하나 바뀔 때마다 + /// 같은 선택자가 다른 것을 고른다. + pub const fn kind_ord(self) -> u8 { + match self { + NodeStep::Section(_) => 0, + NodeStep::Para(_) => 1, + NodeStep::Control(_) => 2, + NodeStep::Cell(_) => 3, + NodeStep::Run(_) => 4, + } + } + + /// 이 마디의 순번. + pub const fn index(self) -> u32 { + match self { + NodeStep::Section(i) + | NodeStep::Para(i) + | NodeStep::Control(i) + | NodeStep::Cell(i) + | NodeStep::Run(i) => i, + } + } +} + +impl fmt::Display for NodeStep { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "/{}[{}]", self.axis_name(), self.index()) + } +} + +/// 문서 뿌리에서 노드까지의 구조 경로. +/// +/// `Ord` 를 유도하는 이유: 선택 결과를 **문서 순서**로 정렬해야 하기 때문이다. +/// 경로의 사전식 순서가 곧 문서 순서다 — 앞 형제는 순번이 작고, 조상은 접두사다. +/// 이 성질 덕에 정렬에 별도 비교기가 필요 없다. +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NodeId { + steps: Vec, +} + +impl NodeId { + /// 문서 뿌리. + pub fn root() -> NodeId { + NodeId { steps: Vec::new() } + } + + /// 마디를 덧붙인 새 경로. + pub fn child(&self, step: NodeStep) -> NodeId { + let mut steps = self.steps.clone(); + steps.push(step); + NodeId { steps } + } + + /// 경로 마디들. + pub fn steps(&self) -> &[NodeStep] { + &self.steps + } + + /// 뿌리로부터의 깊이. + pub fn depth(&self) -> usize { + self.steps.len() + } + + /// 부모 경로. 뿌리면 `None`. + pub fn parent(&self) -> Option { + if self.steps.is_empty() { + return None; + } + Some(NodeId { + steps: self.steps[..self.steps.len() - 1].to_vec(), + }) + } + + /// `other` 가 이 경로의 조상인가. 자기 자신은 조상이 아니다. + /// + /// 자손 결합자(` `)와 `:has` 가 이 판정을 쓴다. 자기 자신을 조상으로 치면 + /// `table table` 이 같은 표를 두 번 고른다. + pub fn is_descendant_of(&self, other: &NodeId) -> bool { + self.steps.len() > other.steps.len() && self.steps.starts_with(&other.steps) + } + + /// 같은 부모를 갖는가. + pub fn is_sibling_of(&self, other: &NodeId) -> bool { + self.steps.len() == other.steps.len() + && !self.steps.is_empty() + && self.steps[..self.steps.len() - 1] == other.steps[..other.steps.len() - 1] + } +} + +impl fmt::Display for NodeId { + /// `/section[0]/para[3]` 형태. 뿌리는 `/`. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.steps.is_empty() { + return f.write_str("/"); + } + for step in &self.steps { + write!(f, "{step}")?; + } + Ok(()) + } +} + +/// 문단 안의 글자 구간 하나. +/// +/// 텍스트를 사본으로 들지 않고 문단과 바이트 범위만 든다 — 구간은 문단마다 +/// 여러 개고, 선택자 하나가 문서 전체의 구간을 훑을 수 있어서 사본 비용이 +/// 곱해진다. +#[derive(Debug, Clone, Copy)] +pub struct RunView<'d> { + para: &'d Paragraph, + byte_start: usize, + byte_end: usize, + char_shape_id: u32, +} + +impl<'d> RunView<'d> { + /// 구간 텍스트. + pub fn text(&self) -> &'d str { + // 경계는 생성 시점에 char_indices 로 구했으므로 항상 글자 경계다. + &self.para.text[self.byte_start..self.byte_end] + } + + /// 이 구간의 글자 모양 ID. + pub const fn char_shape_id(&self) -> u32 { + self.char_shape_id + } + + /// 구간이 속한 문단. + pub const fn paragraph(&self) -> &'d Paragraph { + self.para + } +} + +/// 문단을 글자 모양 변경점으로 잘라 구간 목록을 만든다. +/// +/// ## UTF-16 ↔ 글자 인덱스 +/// +/// `CharShapeRef::start_pos` 는 **UTF-16 코드 유닛** 위치다. 문단 텍스트는 Rust +/// `String`(UTF-8)이므로 그대로 자를 수 없다. `Paragraph::char_offsets[i]` 가 +/// "i 번째 글자의 UTF-16 위치"를 주므로, 이분 탐색으로 되돌린다. +/// +/// ## 손상 입력 방어 +/// +/// `char_offsets` 가 텍스트와 길이가 어긋나거나(손상 문서), `start_pos` 가 +/// 증가하지 않거나, 범위를 넘는 경우가 실제로 있다. 그런 입력에서 패닉하지 +/// 않는 것이 이 함수의 계약이다 — 자를 수 없으면 잘못 자르는 대신 **구간을 +/// 만들지 않는다**. 선택자가 아무것도 못 고르는 것은 복구 가능하지만, 패닉은 +/// 아니다. +pub fn runs_of(para: &Paragraph) -> Vec> { + if para.char_shapes.is_empty() || para.text.is_empty() { + return Vec::new(); + } + + // 글자 인덱스 → 바이트 오프셋 표. 끝에 전체 길이를 하나 더 달아 두면 + // 마지막 구간의 끝을 특수 처리하지 않아도 된다. + let mut byte_at: Vec = para.text.char_indices().map(|(b, _)| b).collect(); + byte_at.push(para.text.len()); + let char_count = byte_at.len() - 1; + + // UTF-16 위치 → 글자 인덱스. + let to_char_index = |utf16_pos: u32| -> usize { + if para.char_offsets.len() == char_count { + // 정상 경로 — 오름차순이라고 가정하되, 손상 시에도 partition_point 는 + // 임의의 값을 돌려줄 뿐 패닉하지 않는다. + para.char_offsets.partition_point(|&o| o < utf16_pos) + } else { + // 표가 어긋난 문서. UTF-16 위치를 글자 위치로 그대로 보되 범위를 조인다. + // 비ASCII 문서에서는 틀린 위치지만, 틀린 위치는 잘못된 선택일 뿐이고 + // 범위를 넘는 슬라이스는 패닉이다. + (utf16_pos as usize).min(char_count) + } + }; + + let mut starts: Vec = para + .char_shapes + .iter() + .map(|cs| to_char_index(cs.start_pos).min(char_count)) + .collect(); + // 첫 구간은 반드시 0 에서 시작한다. 손상 문서에서 첫 start_pos 가 0 이 + // 아니면 앞부분이 어느 구간에도 속하지 않게 되므로 끌어내린다. + starts[0] = 0; + + let mut runs = Vec::with_capacity(starts.len()); + for (i, &start) in starts.iter().enumerate() { + let end = starts.get(i + 1).copied().unwrap_or(char_count); + // 비단조(손상) 구간은 건너뛴다 — 뒤집힌 범위로 슬라이스하면 패닉이다. + if end <= start { + continue; + } + runs.push(RunView { + para, + byte_start: byte_at[start], + byte_end: byte_at[end], + char_shape_id: para.char_shapes[i].char_shape_id, + }); + } + runs +} + +/// 선택 대상 노드 하나 — 종류별 뷰. +#[derive(Debug, Clone, Copy)] +pub enum Node<'d> { + Section(&'d Section), + Para(&'d Paragraph), + Run(RunView<'d>), + Control(&'d Control), + Cell(&'d Cell), +} + +impl<'d> Node<'d> { + /// 이 노드가 축에 맞나. + /// + /// 컨트롤 특수화(`table`·`field` …)는 변종까지 본다 — `table` 은 + /// `control[kind=table]` 과 같은 것을 골라야 한다는 `ast` 의 계약이 여기서 + /// 실현된다. + pub fn matches_axis(&self, axis: Axis) -> bool { + let kind = match axis { + Axis::Any => return true, + Axis::Kind(k) => k, + }; + match (kind, self) { + (AxisKind::Section, Node::Section(_)) => true, + (AxisKind::Para, Node::Para(_)) => true, + (AxisKind::Run, Node::Run(_)) => true, + (AxisKind::Cell, Node::Cell(_)) => true, + (AxisKind::Control, Node::Control(_)) => true, + (k, Node::Control(c)) => control_axis(c) == Some(k), + _ => false, + } + } + + /// 이 노드의 축 이름 — 진단과 봉투 출력에 쓴다. + pub fn axis_name(&self) -> &'static str { + match self { + Node::Section(_) => "section", + Node::Para(_) => "para", + Node::Run(_) => "run", + Node::Cell(_) => "cell", + Node::Control(c) => control_axis(c).map_or("control", AxisKind::name), + } + } + + /// 이 노드의 텍스트 — `:contains`·`:empty`·`text` 속성의 원천. + /// + /// 셀·각주처럼 문단을 담는 노드는 자손 문단 텍스트를 개행으로 잇는다. + /// 컨트롤 일반은 텍스트가 없다(`None`) — 빈 문자열과 구분해야 `[text=""]` + /// 이 "빈 텍스트"만 고르고 "텍스트 개념이 없는 것"은 고르지 않는다. + pub fn text(&self) -> Option { + match self { + Node::Section(s) => Some(join_paragraphs(&s.paragraphs)), + Node::Para(p) => Some(p.text.clone()), + Node::Run(r) => Some(r.text().to_string()), + Node::Cell(c) => Some(join_paragraphs(&c.paragraphs)), + Node::Control(c) => paragraphs_of_control(c).map(join_paragraphs), + } + } +} + +/// 문단 목록의 텍스트를 개행으로 잇는다. +fn join_paragraphs(paras: &[Paragraph]) -> String { + let mut out = String::new(); + for (i, p) in paras.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&p.text); + } + out +} + +/// 컨트롤이 담은 문단 목록. 담지 않으면 `None`. +/// +/// `Field::memo_paragraphs` 를 여기에 넣지 않는 이유: 메모 본문은 본문 흐름이 +/// 아니라 주석이다. `field para` 가 메모 속 문단을 본문 문단과 같은 자격으로 +/// 고르면, 본문만 훑으려던 선택자가 조용히 주석까지 고친다. +pub fn paragraphs_of_control(control: &Control) -> Option<&[Paragraph]> { + match control { + Control::Footnote(f) => Some(&f.paragraphs), + Control::Endnote(e) => Some(&e.paragraphs), + Control::Header(h) => Some(&h.paragraphs), + Control::Footer(f) => Some(&f.paragraphs), + _ => None, + } +} + +/// 컨트롤 변종에 대응하는 전용 축. 전용 축이 없으면 `None`. +pub fn control_axis(control: &Control) -> Option { + match control { + Control::Table(_) => Some(AxisKind::Table), + Control::Picture(_) => Some(AxisKind::Picture), + Control::Equation(_) => Some(AxisKind::Equation), + Control::Field(_) => Some(AxisKind::Field), + Control::Footnote(_) => Some(AxisKind::Footnote), + Control::Endnote(_) => Some(AxisKind::Endnote), + Control::Header(_) => Some(AxisKind::Header), + Control::Footer(_) => Some(AxisKind::Footer), + Control::Bookmark(_) => Some(AxisKind::Bookmark), + Control::Hyperlink(_) => Some(AxisKind::Hyperlink), + Control::Shape(_) => Some(AxisKind::Shape), + _ => None, + } +} + +/// `kind` 속성이 내는 이름 — 전용 축이 있으면 축 이름, 없으면 고유 이름. +/// +/// 전용 축이 없는 컨트롤도 이름을 갖는다. 이름이 없으면 +/// `control[kind=…]` 로 걸러 낼 방법이 사라져서, 축을 만들지 않은 컨트롤은 +/// 아예 지목 불가능해진다. +pub fn control_kind_name(control: &Control) -> &'static str { + if let Some(axis) = control_axis(control) { + return axis.name(); + } + match control { + Control::SectionDef(_) => "sectionDef", + Control::ColumnDef(_) => "columnDef", + Control::AutoNumber(_) => "autoNumber", + Control::NewNumber(_) => "newNumber", + Control::PageNumberPos(_) => "pageNumberPos", + Control::Ruby(_) => "ruby", + Control::CharOverlap(_) => "charOverlap", + Control::PageHide(_) => "pageHide", + Control::HiddenComment(_) => "hiddenComment", + Control::Form(_) => "form", + Control::Unknown(_) => "unknown", + // 전용 축이 있는 변종은 위에서 이미 돌아갔다. + _ => "control", + } +} + +/// 주소가 붙은 노드 — 선택 결과의 단위. +/// +/// ## 경로를 언제 만드나 +/// +/// 순회 중에는 만들지 않는다. 훑는 노드마다 `Vec` 을 복제하면 문서 +/// 크기에 비례해 할당이 쌓이는데, 실제로 경로가 필요한 것은 **후보로 살아남은** +/// 노드뿐이다. 그래서 순회기는 경로 스택 하나를 밀고 당기며 재사용하고 +/// ([`PathStack`]), 후보가 확정되는 순간에만 [`PathStack::snapshot`] 으로 접는다. +#[derive(Debug, Clone)] +pub struct NodeRef<'d> { + /// 뿌리로부터의 경로. + pub id: NodeId, + /// 노드 뷰. + pub node: Node<'d>, + /// 같은 종류 형제 중 순번 (0 기준). + pub index: usize, + /// 같은 종류 형제의 총수 — `:last` 와 음수 인덱스가 쓴다. + pub sibling_count: usize, +} + +/// 순회 중 경로를 재사용하는 스택. +/// +/// 소유 `NodeId` 를 노드마다 만들지 않으려는 장치다. 깊이가 유한하고 +/// (문서 중첩 깊이), 밀고 당기는 비용이 상수이므로 순회 전체가 할당 0 에 +/// 가깝게 돈다. +#[derive(Debug, Clone, Default)] +pub struct PathStack { + steps: Vec, +} + +impl PathStack { + pub fn new() -> PathStack { + PathStack { steps: Vec::new() } + } + + pub fn push(&mut self, step: NodeStep) { + self.steps.push(step); + } + + pub fn pop(&mut self) { + self.steps.pop(); + } + + /// 현재 경로를 소유 값으로 접는다. + pub fn snapshot(&self) -> NodeId { + NodeId { + steps: self.steps.clone(), + } + } + + pub fn depth(&self) -> usize { + self.steps.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::paragraph::CharShapeRef; + + fn para_with(text: &str, shapes: &[(u32, u32)]) -> Paragraph { + let mut p = Paragraph { + text: text.to_string(), + ..Default::default() + }; + // char_offsets: 글자 i 의 UTF-16 위치. + let mut utf16 = 0u32; + for ch in text.chars() { + p.char_offsets.push(utf16); + utf16 += ch.len_utf16() as u32; + } + p.char_shapes = shapes + .iter() + .map(|(start_pos, char_shape_id)| CharShapeRef { + start_pos: *start_pos, + char_shape_id: *char_shape_id, + }) + .collect(); + p + } + + #[test] + fn node_id_renders_as_a_path() { + let id = NodeId::root() + .child(NodeStep::Section(0)) + .child(NodeStep::Para(3)) + .child(NodeStep::Control(0)) + .child(NodeStep::Cell(5)); + assert_eq!(id.to_string(), "/section[0]/para[3]/control[0]/cell[5]"); + assert_eq!(NodeId::root().to_string(), "/"); + } + + #[test] + fn lexicographic_order_is_document_order() { + let a = NodeId::root() + .child(NodeStep::Section(0)) + .child(NodeStep::Para(1)); + let b = NodeId::root() + .child(NodeStep::Section(0)) + .child(NodeStep::Para(2)); + let c = NodeId::root() + .child(NodeStep::Section(1)) + .child(NodeStep::Para(0)); + let mut v = vec![c.clone(), b.clone(), a.clone()]; + v.sort(); + assert_eq!(v, vec![a, b, c]); + } + + #[test] + fn ancestry_excludes_self() { + let parent = NodeId::root().child(NodeStep::Section(0)); + let child = parent.child(NodeStep::Para(0)); + assert!(child.is_descendant_of(&parent)); + assert!(!parent.is_descendant_of(&parent)); + assert!(!parent.is_descendant_of(&child)); + } + + #[test] + fn siblings_share_a_parent() { + let base = NodeId::root().child(NodeStep::Section(0)); + let a = base.child(NodeStep::Para(0)); + let b = base.child(NodeStep::Para(1)); + let other = NodeId::root() + .child(NodeStep::Section(1)) + .child(NodeStep::Para(0)); + assert!(a.is_sibling_of(&b)); + assert!(!a.is_sibling_of(&other)); + // 뿌리는 형제가 없다. + assert!(!NodeId::root().is_sibling_of(&NodeId::root())); + } + + #[test] + fn runs_split_at_char_shape_boundaries() { + let p = para_with("abcdef", &[(0, 10), (3, 20)]); + let runs = runs_of(&p); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].text(), "abc"); + assert_eq!(runs[0].char_shape_id(), 10); + assert_eq!(runs[1].text(), "def"); + assert_eq!(runs[1].char_shape_id(), 20); + } + + #[test] + fn runs_use_utf16_positions_not_byte_positions() { + // 한글은 UTF-16 1 유닛, UTF-8 3 바이트. start_pos 2 는 세 번째 글자다. + let p = para_with("가나다라", &[(0, 1), (2, 2)]); + let runs = runs_of(&p); + assert_eq!(runs[0].text(), "가나"); + assert_eq!(runs[1].text(), "다라"); + } + + #[test] + fn surrogate_pair_counts_as_two_utf16_units() { + // 😀 는 UTF-16 2 유닛이다. start_pos 2 는 그 다음 글자를 가리킨다. + let p = para_with("😀ab", &[(0, 1), (2, 2)]); + let runs = runs_of(&p); + assert_eq!(runs[0].text(), "😀"); + assert_eq!(runs[1].text(), "ab"); + } + + #[test] + fn empty_char_shapes_yield_no_runs() { + let p = para_with("abc", &[]); + assert!(runs_of(&p).is_empty()); + } + + #[test] + fn corrupt_offsets_do_not_panic() { + // char_offsets 가 텍스트와 어긋난 문서. + let mut p = para_with("가나다", &[(0, 1), (99, 2)]); + p.char_offsets.clear(); + let runs = runs_of(&p); + // 자를 수 없으면 덜 자를 뿐, 패닉하지 않는다. + assert!(runs.iter().all(|r| !r.text().is_empty())); + } + + #[test] + fn non_monotonic_shape_positions_are_skipped_not_panicked() { + let p = para_with("abcdef", &[(0, 1), (5, 2), (2, 3)]); + let runs = runs_of(&p); + // 뒤집힌 구간은 버린다. 남은 구간은 전부 유효한 슬라이스여야 한다. + assert!(runs.iter().all(|r| !r.text().is_empty())); + } + + #[test] + fn first_shape_is_pulled_down_to_zero() { + // 손상 문서에서 첫 start_pos 가 0 이 아니면 앞부분이 유실된다. + let p = para_with("abcdef", &[(2, 7)]); + let runs = runs_of(&p); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].text(), "abcdef"); + } + + #[test] + fn every_control_specialization_is_addressable_as_a_kind() { + // 전용 축을 가진 컨트롤은 `control[kind=<축이름>]` 로도 지목할 수 있어야 + // 한다. 어긋나면 `table` 과 `control[kind=table]` 이 다른 것을 고른다. + use super::super::ast::AXIS_NAMES; + for (_, kind) in AXIS_NAMES { + if kind.specializes() == Some(AxisKind::Control) { + assert!( + AXIS_NAMES.iter().any(|(n, _)| *n == kind.name()), + "`{}` 가 kind 어휘에 없다", + kind.name() + ); + } + } + } + + #[test] + fn path_stack_snapshots_the_current_path() { + let mut st = PathStack::new(); + st.push(NodeStep::Section(2)); + st.push(NodeStep::Para(7)); + assert_eq!(st.snapshot().to_string(), "/section[2]/para[7]"); + assert_eq!(st.depth(), 2); + st.pop(); + assert_eq!(st.snapshot().to_string(), "/section[2]"); + } +} diff --git a/src/agent/dsel/parse.rs b/src/agent/dsel/parse.rs new file mode 100644 index 0000000000..1b0f6b503c --- /dev/null +++ b/src/agent/dsel/parse.rs @@ -0,0 +1,884 @@ +//! DSEL 파서 — 재귀 하강, 축 사전 대조, 상한 강제. +//! +//! ## 파싱 중에 의미까지 검사하는 이유 +//! +//! 축 이름·속성 이름·연산자 적합성을 **파싱 단계에서** 확인한다. 문법만 보고 +//! 통과시킨 뒤 평가에서 거절하면, 오류 위치가 "이 선택자 어딘가"로 뭉개진다. +//! 파싱 중에는 지금 어느 축의 어느 속성을 읽는 중인지 정확히 알고 있으므로 +//! `축 para 에 없는 속성 rows — 기대: text|len|styleId…` 처럼 후보까지 낼 수 있다. +//! 진단 품질은 에이전트 왕복 횟수로 바로 환산된다. +//! +//! ## 상한을 파서가 강제하는 이유 +//! +//! 선택자는 **모델이 만들고 문서에 맞대는 값**이다. 즉 길이도 중첩도 신뢰 +//! 경계 밖에서 온다. 중첩 `:has(:has(:has(…)))` 는 재귀 하강 파서에서 그대로 +//! 스택 깊이가 되고, 그건 손상 입력 스택 오버플로(#4830 과 같은 부류)다. +//! 상한은 평가기가 아니라 여기 있어야 한다 — 평가까지 가기 전에 막아야 +//! 스택이 이미 깊어진 상태를 피한다. + +use super::ast::{ + unknown_attr, unknown_axis, AttrPred, AttrType, Axis, AxisKind, CmpOp, Combinator, Literal, + Path, Pred, Pseudo, PseudoArity, PseudoDef, Selector, Step, AXIS_NAMES, PSEUDO_DEFS, +}; +use super::error::SelectorError; +use super::glob::Glob; +use super::lex::{lex, Lexed}; +use super::token::{Tok, Token}; + +/// 선택자 원문 최대 길이(문자). +/// +/// 4096 은 넉넉하다 — 실무에서 가장 긴 선택자도 200자를 넘지 않는다. 상한의 +/// 목적은 표현력 제한이 아니라 "무한히 긴 입력이 파서에 들어오지 않는다"는 +/// 보장이다. +pub const MAX_SOURCE_CHARS: usize = 4096; + +/// `:has`/`:not` 중첩 최대 깊이. +pub const MAX_NESTING: usize = 8; + +/// 합집합 가지 최대 개수. +pub const MAX_PATHS: usize = 64; + +/// 한 경로의 최대 스텝 수. +pub const MAX_STEPS: usize = 32; + +/// 한 스텝의 최대 술어 수. +pub const MAX_PREDS: usize = 16; + +/// 선택자 문자열을 파싱한다. +pub fn parse(source: &str) -> Result { + let char_len = source.chars().count(); + if char_len > MAX_SOURCE_CHARS { + return Err(SelectorError::limit( + MAX_SOURCE_CHARS, + format!("선택자가 너무 길다 ({char_len}자, 상한 {MAX_SOURCE_CHARS}자)"), + )); + } + + let lexed = lex(source)?; + let mut parser = Parser { + toks: &lexed.tokens, + char_len: lexed.char_len, + pos: 0, + depth: 0, + }; + + let paths = parser.parse_paths(source)?; + parser.skip_ws(); + if let Some(tok) = parser.peek() { + let offset = parser.offset(); + return Err( + SelectorError::parse(offset, format!("선택자 끝에 남은 {}", tok.describe())) + .expecting([","]), + ); + } + + Ok(Selector { + paths, + source: source.to_string(), + }) +} + +struct Parser<'a> { + toks: &'a [Token], + char_len: usize, + pos: usize, + depth: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<&'a Tok> { + self.toks.get(self.pos).map(|t| &t.tok) + } + + /// 현재 위치의 문자 오프셋. 입력 끝이면 전체 길이. + fn offset(&self) -> usize { + self.toks + .get(self.pos) + .map(|t| t.offset) + .unwrap_or(self.char_len) + } + + fn bump(&mut self) -> Option<&'a Tok> { + let t = self.toks.get(self.pos).map(|t| &t.tok); + if t.is_some() { + self.pos += 1; + } + t + } + + /// 공백 토큰을 흘린다. 실제로 흘렸는지 돌려준다 — 자손 결합자 판정에 쓴다. + fn skip_ws(&mut self) -> bool { + let mut seen = false; + while matches!(self.peek(), Some(Tok::Ws)) { + self.pos += 1; + seen = true; + } + seen + } + + fn expect(&mut self, want: &Tok, what: &str) -> Result<(), SelectorError> { + let offset = self.offset(); + match self.peek() { + Some(t) if t == want => { + self.pos += 1; + Ok(()) + } + Some(t) => Err( + SelectorError::parse(offset, format!("{what} 자리에 {}", t.describe())) + .expecting([want.symbol()]), + ), + None => Err( + SelectorError::parse(offset, format!("{what} 없이 선택자가 끝났다")) + .expecting([want.symbol()]), + ), + } + } + + /// 쉼표로 이어진 경로들. + fn parse_paths(&mut self, source: &str) -> Result, SelectorError> { + let mut paths = Vec::new(); + loop { + self.skip_ws(); + paths.push(self.parse_path(source)?); + if paths.len() > MAX_PATHS { + return Err(SelectorError::limit( + self.offset(), + format!("합집합 가지가 너무 많다 (상한 {MAX_PATHS})"), + )); + } + self.skip_ws(); + if matches!(self.peek(), Some(Tok::Comma)) { + self.pos += 1; + continue; + } + break; + } + Ok(paths) + } + + /// 결합자로 이어진 스텝들. + fn parse_path(&mut self, source: &str) -> Result { + let mut steps = Vec::new(); + let mut combinator = Combinator::Root; + + loop { + steps.push(self.parse_step(combinator, source)?); + if steps.len() > MAX_STEPS { + return Err(SelectorError::limit( + self.offset(), + format!("경로 스텝이 너무 많다 (상한 {MAX_STEPS})"), + )); + } + + // 다음 결합자를 정한다. 공백을 흘리기 **전** 위치를 기억해 두는 이유: + // 공백 뒤가 `,` 나 `)` 면 그 공백은 결합자가 아니라 여백이므로 + // 되감아야 상위 규칙이 같은 공백을 다시 처리할 수 있다. + let save = self.pos; + let had_ws = self.skip_ws(); + combinator = match self.peek() { + None => break, + Some(Tok::Comma) | Some(Tok::RParen) => { + self.pos = save; + break; + } + Some(Tok::Gt) => { + self.pos += 1; + self.skip_ws(); + Combinator::Child + } + Some(Tok::Plus) => { + self.pos += 1; + self.skip_ws(); + Combinator::NextSibling + } + Some(Tok::Tilde) => { + self.pos += 1; + self.skip_ws(); + Combinator::FollowingSibling + } + Some(_) if had_ws => Combinator::Descendant, + Some(tok) => { + let offset = self.offset(); + return Err(SelectorError::parse( + offset, + format!("스텝 뒤에 예상 밖 {}", tok.describe()), + ) + .expecting([">", "+", "~", ",", "공백"])); + } + }; + } + + Ok(Path { steps }) + } + + /// 축 + 술어들. + fn parse_step(&mut self, combinator: Combinator, source: &str) -> Result { + let offset = self.offset(); + let axis = match self.peek() { + Some(Tok::Star) => { + self.pos += 1; + Axis::Any + } + Some(Tok::Ident(name)) => { + let name = name.clone(); + self.pos += 1; + match AxisKind::from_name(&name) { + Some(k) => Axis::Kind(k), + None => return Err(unknown_axis(&name, offset)), + } + } + // 축을 생략한 스텝은 `*` 이다 — CSS 의 `:not(:empty)`·`[k=v]` 와 같은 + // 규칙. 토큰을 소비하지 않으므로 뒤이어 술어가 반드시 하나 이상 붙고, + // 따라서 폭 0 스텝이 만들어지는 경로는 없다(무한 루프 불가). + Some(Tok::LBracket) | Some(Tok::Colon) => Axis::Any, + Some(tok) => { + return Err( + SelectorError::parse(offset, format!("축 자리에 {}", tok.describe())) + .expecting(AXIS_NAMES.iter().map(|(n, _)| *n).chain(["*"])), + ); + } + None => { + return Err(SelectorError::parse(offset, "축 없이 선택자가 끝났다") + .expecting(AXIS_NAMES.iter().map(|(n, _)| *n).chain(["*"]))); + } + }; + + let mut preds = Vec::new(); + loop { + match self.peek() { + Some(Tok::LBracket) => preds.push(Pred::Attr(self.parse_attr(axis)?)), + Some(Tok::Colon) => preds.push(Pred::Pseudo(self.parse_pseudo(source)?)), + _ => break, + } + if preds.len() > MAX_PREDS { + return Err(SelectorError::limit( + self.offset(), + format!("한 스텝의 술어가 너무 많다 (상한 {MAX_PREDS})"), + )); + } + } + + Ok(Step { + combinator, + axis, + preds, + offset, + }) + } + + /// `[name]` 또는 `[name op value]`. + fn parse_attr(&mut self, axis: Axis) -> Result { + self.expect(&Tok::LBracket, "속성 술어 시작")?; + self.skip_ws(); + + let name_offset = self.offset(); + let name = match self.bump() { + Some(Tok::Ident(n)) => n.clone(), + Some(tok) => { + return Err(SelectorError::parse( + name_offset, + format!("속성 이름 자리에 {}", tok.describe()), + ) + .expecting(axis.attributes().iter().map(|a| a.name))); + } + None => { + return Err( + SelectorError::parse(name_offset, "속성 이름 없이 선택자가 끝났다") + .expecting(axis.attributes().iter().map(|a| a.name)), + ); + } + }; + + let def = match axis.attributes().iter().find(|a| a.name == name) { + Some(d) => *d, + None => return Err(unknown_attr(axis, &name, name_offset)), + }; + + self.skip_ws(); + let compare = if self.peek().is_some_and(Tok::is_compare_op) { + let op_offset = self.offset(); + let op = cmp_op(self.bump().expect("직전에 확인했다")); + if !def.ty.accepts(op) { + return Err(SelectorError::resolve( + op_offset, + format!( + "{} 타입 속성 `{name}` 에는 `{}` 를 쓸 수 없다", + def.ty.as_str(), + op.symbol() + ), + ) + .expecting( + ALL_OPS + .iter() + .filter(|o| def.ty.accepts(**o)) + .map(|o| o.symbol()), + ) + .hinting(match def.ty { + AttrType::Str => { + "문자열은 대소 비교를 하지 않는다 — 로캘에 따라 답이 달라지기 때문" + } + AttrType::Int => "정수에는 부분 일치를 쓸 수 없다", + AttrType::Bool => "불리언은 `=` 와 `!=` 만 받는다", + })); + } + + self.skip_ws(); + let lit_offset = self.offset(); + let lit = self.parse_literal(def.ty, &name, lit_offset)?; + + // 글롭은 여기서 컴파일해 본다. 평가까지 미루면 패턴 오류가 "결과 0건" + // 으로 둔갑해 원인을 알 수 없게 된다. + if op == CmpOp::Glob { + if let Literal::Str(pattern) = &lit { + Glob::compile(pattern, lit_offset + 1)?; + } + } + + Some((op, lit)) + } else { + None + }; + + self.skip_ws(); + self.expect(&Tok::RBracket, "속성 술어 끝")?; + + Ok(AttrPred { + name, + compare, + offset: name_offset, + }) + } + + /// 속성 타입에 맞는 리터럴 하나. + fn parse_literal( + &mut self, + ty: AttrType, + attr: &str, + offset: usize, + ) -> Result { + let tok = self.bump().cloned(); + let lit = + match tok { + Some(Tok::Str(s)) => Literal::Str(s), + Some(Tok::Int(n)) => Literal::Int(n), + // 따옴표 없는 식별자는 문자열 값으로 본다 — `[kind=table]` 이 가장 흔한 + // 모양이라 매번 따옴표를 요구하면 실수 유발이 더 크다. `true`/`false` + // 만 불리언으로 승격한다. + Some(Tok::Ident(s)) => match s.as_str() { + "true" => Literal::Bool(true), + "false" => Literal::Bool(false), + _ => Literal::Str(s), + }, + Some(tok) => { + return Err(SelectorError::parse( + offset, + format!("값 자리에 {}", tok.describe()), + ) + .expecting(["문자열", "정수", "식별자"])); + } + None => { + return Err(SelectorError::parse(offset, "값 없이 선택자가 끝났다") + .expecting(["문자열", "정수", "식별자"])); + } + }; + + let ok = matches!( + (ty, &lit), + (AttrType::Str, Literal::Str(_)) + | (AttrType::Int, Literal::Int(_)) + | (AttrType::Bool, Literal::Bool(_)) + ); + if !ok { + let hint = match ty { + AttrType::Str => "문자열 값은 따옴표로 감싸거나 따옴표 없는 이름으로 적는다", + AttrType::Int => "정수 값에 따옴표를 쓰지 않는다", + AttrType::Bool => "`true` 또는 `false` 만 온다", + }; + return Err(SelectorError::resolve( + offset, + format!( + "속성 `{attr}` 은 {} 인데 {} 값이 왔다", + ty.as_str(), + lit.type_name() + ), + ) + .expecting([ty.as_str()]) + .hinting(hint)); + } + + Ok(lit) + } + + /// `:name` 또는 `:name(args)`. + fn parse_pseudo(&mut self, source: &str) -> Result { + self.expect(&Tok::Colon, "의사 선택자 시작")?; + let name_offset = self.offset(); + let name = match self.bump() { + Some(Tok::Ident(n)) => n.clone(), + Some(tok) => { + return Err(SelectorError::parse( + name_offset, + format!("의사 선택자 이름 자리에 {}", tok.describe()), + ) + .expecting(PSEUDO_DEFS.iter().map(|d| d.name))); + } + None => { + return Err(SelectorError::parse( + name_offset, + "의사 선택자 이름 없이 선택자가 끝났다", + ) + .expecting(PSEUDO_DEFS.iter().map(|d| d.name))); + } + }; + + let def = match PseudoDef::from_name(&name) { + Some(d) => d, + None => { + let err = + SelectorError::resolve(name_offset, format!("알 수 없는 의사 선택자 `{name}`")) + .expecting(PSEUDO_DEFS.iter().map(|d| d.name)); + return Err( + match super::ast::nearest(&name, PSEUDO_DEFS.iter().map(|d| d.name)) { + Some(c) => err.hinting(format!("`:{c}` 를 뜻했나")), + None => err, + }, + ); + } + }; + + if def.arity == PseudoArity::None { + if matches!(self.peek(), Some(Tok::LParen)) { + return Err(SelectorError::resolve( + self.offset(), + format!("`:{name}` 은 인자를 받지 않는다"), + ) + .hinting("괄호를 지운다")); + } + return Ok(match name.as_str() { + "first" => Pseudo::First, + "last" => Pseudo::Last, + "empty" => Pseudo::Empty, + other => unreachable!("무인자 의사 선택자 `{other}` 가 사전에만 있다"), + }); + } + + self.expect(&Tok::LParen, format!("`:{name}` 의 인자 목록").as_str())?; + self.skip_ws(); + + let pseudo = match def.arity { + PseudoArity::None => unreachable!("위에서 돌아갔다"), + PseudoArity::Int => Pseudo::Nth(self.parse_int_arg(&name)?), + PseudoArity::Str => { + let offset = self.offset(); + let text = self.parse_str_arg(&name)?; + if name == "matches" { + // 인자 문자열의 첫 글자는 여는 따옴표 다음이다. + Glob::compile(&text, offset + 1)?; + Pseudo::Matches(text) + } else { + Pseudo::Contains(text) + } + } + PseudoArity::Range => { + let from = self.parse_int_arg(&name)?; + self.skip_ws(); + self.expect(&Tok::DotDot, "범위 구분자")?; + self.skip_ws(); + let to = self.parse_int_arg(&name)?; + // 같은 부호끼리만 대소를 판정할 수 있다. `0..-1` 은 "처음부터 + // 마지막 직전까지"라는 뜻이 성립하므로 오류가 아니다. + if from.signum() == to.signum() && from > to { + return Err(SelectorError::resolve( + self.offset(), + format!("뒤집힌 범위 `{from}..{to}`"), + ) + .hinting("반열림 구간이라 시작이 끝보다 작아야 한다")); + } + Pseudo::Range { from, to } + } + PseudoArity::Selector => { + if self.depth + 1 > MAX_NESTING { + return Err(SelectorError::limit( + self.offset(), + format!("중첩 선택자가 너무 깊다 (상한 {MAX_NESTING})"), + )); + } + self.depth += 1; + let paths = self.parse_paths(source)?; + self.depth -= 1; + let inner = Selector { + paths, + source: source.to_string(), + }; + match name.as_str() { + "not" => Pseudo::Not(Box::new(inner)), + "has" => Pseudo::Has(Box::new(inner)), + other => unreachable!("선택자 인자 의사 선택자 `{other}` 가 사전에만 있다"), + } + } + }; + + self.skip_ws(); + self.expect(&Tok::RParen, format!("`:{name}` 의 인자 목록 끝").as_str())?; + Ok(pseudo) + } + + fn parse_int_arg(&mut self, pseudo: &str) -> Result { + let offset = self.offset(); + match self.bump() { + Some(Tok::Int(n)) => Ok(*n), + Some(tok) => Err(SelectorError::parse( + offset, + format!("`:{pseudo}` 인자 자리에 {}", tok.describe()), + ) + .expecting(["정수"])), + None => Err(SelectorError::parse( + offset, + format!("`:{pseudo}` 인자 없이 선택자가 끝났다"), + ) + .expecting(["정수"])), + } + } + + fn parse_str_arg(&mut self, pseudo: &str) -> Result { + let offset = self.offset(); + match self.bump() { + Some(Tok::Str(s)) => Ok(s.clone()), + Some(Tok::Ident(s)) => Ok(s.clone()), + Some(tok) => Err(SelectorError::parse( + offset, + format!("`:{pseudo}` 인자 자리에 {}", tok.describe()), + ) + .expecting(["문자열"]) + .hinting("공백이나 기호가 든 값은 따옴표로 감싼다")), + None => Err(SelectorError::parse( + offset, + format!("`:{pseudo}` 인자 없이 선택자가 끝났다"), + ) + .expecting(["문자열"])), + } + } +} + +/// 비교 연산자 전체 — 오류 메시지의 후보 목록에 쓴다. +const ALL_OPS: &[CmpOp] = &[ + CmpOp::Eq, + CmpOp::Ne, + CmpOp::Gt, + CmpOp::Lt, + CmpOp::Ge, + CmpOp::Le, + CmpOp::Prefix, + CmpOp::Suffix, + CmpOp::Substr, + CmpOp::Glob, +]; + +fn cmp_op(tok: &Tok) -> CmpOp { + match tok { + Tok::Eq => CmpOp::Eq, + Tok::Ne => CmpOp::Ne, + Tok::Gt => CmpOp::Gt, + Tok::Lt => CmpOp::Lt, + Tok::Ge => CmpOp::Ge, + Tok::Le => CmpOp::Le, + Tok::Prefix => CmpOp::Prefix, + Tok::Suffix => CmpOp::Suffix, + Tok::Substr => CmpOp::Substr, + Tok::Glob => CmpOp::Glob, + other => unreachable!("비교 연산자가 아닌 {other:?} 로 호출됐다"), + } +} + +/// 렉싱 결과를 재사용하는 내부 진입점 — 스키마 산출기가 문법 예시를 검증할 때 쓴다. +#[allow(dead_code)] +pub(crate) fn parse_lexed(lexed: &Lexed, source: &str) -> Result { + let mut parser = Parser { + toks: &lexed.tokens, + char_len: lexed.char_len, + pos: 0, + depth: 0, + }; + let paths = parser.parse_paths(source)?; + Ok(Selector { + paths, + source: source.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ok(src: &str) -> Selector { + parse(src).unwrap_or_else(|e| panic!("파싱 실패: {}", e.render(src))) + } + + fn err(src: &str) -> SelectorError { + parse(src).expect_err("파싱이 성공하면 안 된다") + } + + #[test] + fn single_axis() { + let sel = ok("para"); + assert_eq!(sel.paths.len(), 1); + assert_eq!(sel.paths[0].steps.len(), 1); + assert_eq!(sel.paths[0].steps[0].axis, Axis::Kind(AxisKind::Para)); + assert_eq!(sel.paths[0].steps[0].combinator, Combinator::Root); + } + + #[test] + fn combinators_are_distinguished() { + let sel = ok("section > table cell + para ~ run"); + let combs: Vec = sel.paths[0].steps.iter().map(|s| s.combinator).collect(); + assert_eq!( + combs, + vec![ + Combinator::Root, + Combinator::Child, + Combinator::Descendant, + Combinator::NextSibling, + Combinator::FollowingSibling, + ] + ); + } + + /// 오프셋·원문을 뺀 구조만 뽑는다 — 공백 차이는 오프셋을 바꾸므로 + /// `Selector` 통째 비교로는 "구조가 같다"를 확인할 수 없다. + fn shape(sel: &Selector) -> Vec> { + sel.paths + .iter() + .map(|p| p.steps.iter().map(|s| (s.combinator, s.axis)).collect()) + .collect() + } + + #[test] + fn whitespace_around_explicit_combinators_is_optional() { + assert_eq!(shape(&ok("section>table")), shape(&ok("section > table"))); + assert_eq!(shape(&ok("para+para")), shape(&ok("para + para"))); + assert_eq!(shape(&ok("para~para")), shape(&ok("para\t~\tpara"))); + } + + #[test] + fn union_paths_split_on_comma() { + let sel = ok("para, table, cell"); + assert_eq!(sel.paths.len(), 3); + } + + #[test] + fn trailing_whitespace_is_not_a_descendant_combinator() { + // 끝의 공백이 결합자로 읽히면 "축 없이 끝났다"로 죽는다. + ok("para "); + ok(" para , table "); + } + + #[test] + fn attribute_predicates_bind_to_the_axis() { + let sel = ok(r#"para[text*="합계"]"#); + match &sel.paths[0].steps[0].preds[0] { + Pred::Attr(a) => { + assert_eq!(a.name, "text"); + assert_eq!( + a.compare, + Some((CmpOp::Substr, Literal::Str("합계".into()))) + ); + } + other => panic!("속성 술어가 아니다: {other:?}"), + } + } + + #[test] + fn unknown_axis_names_the_candidates() { + let e = err("tabel"); + assert!(e.hint.unwrap().contains("table")); + } + + #[test] + fn attribute_unknown_on_this_axis_is_rejected_even_if_valid_elsewhere() { + // `rows` 는 table 의 속성이지 para 의 속성이 아니다. 문법만 보는 파서라면 + // 통과시켰을 자리. + let e = err("para[rows>1]"); + assert!(e.message.contains("축 `para` 에 없는 속성")); + } + + #[test] + fn string_attributes_reject_ordering_and_explain_why() { + let e = err(r#"para[text>="가"]"#); + assert!(e.hint.unwrap().contains("로캘")); + } + + #[test] + fn int_attribute_rejects_quoted_value() { + let e = err(r#"para[len="3"]"#); + assert!(e.message.contains("integer")); + assert!(e.hint.unwrap().contains("따옴표")); + } + + #[test] + fn bare_identifier_is_a_string_value() { + let sel = ok("control[kind=table]"); + match &sel.paths[0].steps[0].preds[0] { + Pred::Attr(a) => assert_eq!(a.compare, Some((CmpOp::Eq, Literal::Str("table".into())))), + other => panic!("{other:?}"), + } + } + + #[test] + fn true_false_become_booleans() { + let sel = ok("para[empty=true]"); + match &sel.paths[0].steps[0].preds[0] { + Pred::Attr(a) => assert_eq!(a.compare, Some((CmpOp::Eq, Literal::Bool(true)))), + other => panic!("{other:?}"), + } + } + + #[test] + fn bare_attribute_is_an_existence_test() { + let sel = ok("field[name]"); + match &sel.paths[0].steps[0].preds[0] { + Pred::Attr(a) => assert!(a.compare.is_none()), + other => panic!("{other:?}"), + } + } + + #[test] + fn pseudo_arity_is_enforced() { + assert!(err("para:first(1)").message.contains("인자를 받지 않는다")); + assert!(err("para:nth").message.contains("인자 목록")); + assert!(err("para:nth(x)").message.contains("인자 자리에")); + } + + #[test] + fn range_pseudo_parses_and_rejects_inversion() { + let sel = ok("para:range(1..4)"); + match &sel.paths[0].steps[0].preds[0] { + Pred::Pseudo(Pseudo::Range { from, to }) => { + assert_eq!((*from, *to), (1, 4)); + } + other => panic!("{other:?}"), + } + assert!(err("para:range(4..1)").message.contains("뒤집힌 범위")); + // 부호가 다르면 뒤집힌 것이 아니다 — `0..-1` 은 유효한 뜻을 갖는다. + ok("para:range(0..-1)"); + } + + #[test] + fn glob_patterns_are_validated_at_parse_time() { + let e = err(r#"para[text~="[abc"]"#); + assert!(e.message.contains("닫히지 않은 글자 집합")); + let e2 = err(r#"para:matches("[z-a]")"#); + assert!(e2.message.contains("뒤집힌")); + } + + #[test] + fn glob_error_offset_points_inside_the_pattern() { + // 패턴 안의 오류인데 오프셋이 선택자 시작을 가리키면 캐럿이 쓸모없다. + let e = err(r#"para[text~="[abc"]"#); + assert!(e.offset > 10, "오프셋이 패턴 안이어야 한다: {}", e.offset); + } + + #[test] + fn nested_selectors_parse() { + let sel = ok("para:has(table)"); + match &sel.paths[0].steps[0].preds[0] { + Pred::Pseudo(Pseudo::Has(inner)) => { + assert_eq!(inner.paths[0].steps[0].axis, Axis::Kind(AxisKind::Table)); + } + other => panic!("{other:?}"), + } + ok("para:not(:empty)"); + ok("table:has(cell[text*=\"합계\"])"); + } + + #[test] + fn nesting_depth_is_capped() { + let mut src = String::from("para"); + for _ in 0..(MAX_NESTING + 2) { + src = format!("para:has({src})"); + } + let e = parse(&src).expect_err("상한을 넘겨야 한다"); + assert!(e.message.contains("너무 깊다")); + } + + #[test] + fn source_length_is_capped_before_lexing() { + let src = "a".repeat(MAX_SOURCE_CHARS + 1); + let e = parse(&src).expect_err("상한을 넘겨야 한다"); + assert!(e.message.contains("너무 길다")); + } + + #[test] + fn step_count_is_capped() { + let src = vec!["para"; MAX_STEPS + 2].join(" > "); + let e = parse(&src).expect_err("상한을 넘겨야 한다"); + assert!(e.message.contains("스텝이 너무 많다")); + } + + #[test] + fn omitted_axis_is_the_wildcard_like_css() { + // `:not(:empty)` 의 안쪽 스텝에는 축이 없다. CSS 와 같은 규칙으로 `*` 이다. + let sel = ok("[index=0]"); + assert_eq!(sel.paths[0].steps[0].axis, Axis::Any); + assert_eq!(sel.paths[0].steps[0].preds.len(), 1); + ok("para:not(:empty)"); + ok(":first"); + } + + #[test] + fn space_before_a_predicate_reports_the_wildcard_cause() { + // `para [len>0]` 은 문법 오류가 아니라 `para` 의 자손 `*[len>0]` 이다. + // 그리고 `*` 에는 `len` 이 없다 — 진단이 그 인과를 그대로 말해야 한다. + let e = err("para [len>0]"); + assert!(e.message.contains("축 `*` 에 없는 속성")); + assert!(e.hint.unwrap().contains("자손 결합자")); + } + + #[test] + fn dangling_combinator_is_rejected() { + assert!(err("para >").message.contains("축 없이")); + assert!(err("> para").message.contains("축 자리에")); + assert!(err("para,").message.contains("축 없이")); + } + + #[test] + fn result_axes_uses_only_the_last_step() { + let sel = ok("table cell, section para"); + let axes = sel.result_axes(); + assert_eq!(axes.len(), 2); + assert!(axes.contains(&Axis::Kind(AxisKind::Cell))); + assert!(axes.contains(&Axis::Kind(AxisKind::Para))); + // 경유 축은 들어가지 않는다. + assert!(!axes.contains(&Axis::Kind(AxisKind::Table))); + } + + #[test] + fn max_nesting_is_reported_from_the_ast() { + assert_eq!(ok("para").max_nesting(), 0); + assert_eq!(ok("para:has(table)").max_nesting(), 1); + assert_eq!(ok("para:has(table:has(cell))").max_nesting(), 2); + } + + #[test] + fn wildcard_axis_only_takes_common_attributes() { + ok("*[index=0]"); + assert!(err("*[text=\"x\"]").message.contains("축 `*` 에 없는 속성")); + } + + #[test] + fn korean_values_survive_parsing() { + let sel = ok(r#"field[name="수급자성명"]"#); + match &sel.paths[0].steps[0].preds[0] { + Pred::Attr(a) => assert_eq!( + a.compare, + Some((CmpOp::Eq, Literal::Str("수급자성명".into()))) + ), + other => panic!("{other:?}"), + } + } + + #[test] + fn trailing_garbage_is_reported_at_the_right_place() { + let e = err("para]"); + assert_eq!(e.offset, 4); + } +} diff --git a/src/agent/dsel/token.rs b/src/agent/dsel/token.rs new file mode 100644 index 0000000000..d04c95c79d --- /dev/null +++ b/src/agent/dsel/token.rs @@ -0,0 +1,182 @@ +//! DSEL 토큰 정의. +//! +//! ## 왜 공백이 토큰인가 +//! +//! DSEL 은 CSS 처럼 **공백 자체가 결합자**다 (`table cell` = 표 아래 어딘가의 셀, +//! `table > cell` = 표의 직계 셀). 렉서가 공백을 버리면 이 둘을 구분할 방법이 +//! 사라진다. 그래서 공백은 [`Tok::Ws`] 로 남기고, 의미가 없는 자리(괄호 안, +//! 결합자 주변)에서 파서가 **명시적으로** 흘려보낸다. "렉서가 공백을 지운다"는 +//! 흔한 기본값이 여기서는 문법 파괴다. +//! +//! ## 비교 연산자를 렉서가 아는 이유 +//! +//! `>` 는 대괄호 밖에서는 직계 결합자, 안에서는 비교 연산자다. 렉싱을 문맥에 +//! 의존시키면(대괄호 깊이를 렉서가 세면) 렉서와 파서가 같은 상태를 두 벌 갖게 +//! 되고, 두 벌은 언젠가 어긋난다. 그래서 렉서는 **최장 일치로 기호만 확정**하고 +//! (`>=` 는 한 토큰, `>` 는 한 토큰), 그게 결합자인지 비교자인지는 파서가 정한다. + +use std::fmt; + +/// 토큰 한 개 — 종류와 입력에서의 문자 오프셋. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// 토큰 종류와 실린 값. + pub tok: Tok, + /// 토큰이 시작한 **문자** 오프셋(바이트 아님 — `error` 모듈과 같은 규약). + pub offset: usize, +} + +impl Token { + pub fn new(tok: Tok, offset: usize) -> Self { + Token { tok, offset } + } +} + +/// 토큰 종류. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tok { + /// 식별자 — 축 이름·속성 이름·의사 이름·`last` 같은 문맥 키워드. + /// + /// 키워드를 따로 토큰화하지 않는 이유: `last` 는 `:nth(last)` 에서는 키워드지만 + /// `para[style="last"]` 에서는 그냥 값이고, `[name=last]` 에서는 따옴표 없는 + /// 값이다. 렉서가 키워드로 승격시키면 파서는 매번 도로 식별자로 강등해야 한다. + Ident(String), + /// 따옴표 문자열 — 이스케이프가 이미 풀린 값. + Str(String), + /// 정수. 음수 허용(`:nth(-1)` 은 뒤에서 첫째). + Int(i64), + /// `*` — 전체 축 와일드카드. + Star, + /// `[` + LBracket, + /// `]` + RBracket, + /// `(` + LParen, + /// `)` + RParen, + /// `:` + Colon, + /// `,` + Comma, + /// `..` — 범위. + DotDot, + /// `>` — 직계 결합자 또는 초과 비교. + Gt, + /// `<` — 미만 비교(결합자 아님). + Lt, + /// `>=` + Ge, + /// `<=` + Le, + /// `=` + Eq, + /// `!=` + Ne, + /// `^=` — 접두 일치. + Prefix, + /// `$=` — 접미 일치. + Suffix, + /// `*=` — 부분 일치. + Substr, + /// `~=` — 글롭 일치. + Glob, + /// `+` — 다음 형제 결합자. + Plus, + /// `~` — 이후 형제 결합자. + Tilde, + /// 공백 한 덩어리 — 자손 결합자 후보. + Ws, +} + +impl Tok { + /// 오류 메시지에 쓸 표시 이름. + /// + /// 값을 실은 토큰은 값까지 보여 준다 — `기대: ]` 옆에 `실제: para` 가 붙어야 + /// 무엇을 지웠는지 알 수 있다. + pub fn describe(&self) -> String { + match self { + Tok::Ident(s) => format!("식별자 `{s}`"), + Tok::Str(s) => format!("문자열 \"{s}\""), + Tok::Int(n) => format!("정수 {n}"), + other => format!("`{}`", other.symbol()), + } + } + + /// 기호 토큰의 원문. 값 토큰은 종류 이름을 돌려준다. + pub fn symbol(&self) -> &'static str { + match self { + Tok::Ident(_) => "식별자", + Tok::Str(_) => "문자열", + Tok::Int(_) => "정수", + Tok::Star => "*", + Tok::LBracket => "[", + Tok::RBracket => "]", + Tok::LParen => "(", + Tok::RParen => ")", + Tok::Colon => ":", + Tok::Comma => ",", + Tok::DotDot => "..", + Tok::Gt => ">", + Tok::Lt => "<", + Tok::Ge => ">=", + Tok::Le => "<=", + Tok::Eq => "=", + Tok::Ne => "!=", + Tok::Prefix => "^=", + Tok::Suffix => "$=", + Tok::Substr => "*=", + Tok::Glob => "~=", + Tok::Plus => "+", + Tok::Tilde => "~", + Tok::Ws => "공백", + } + } + + /// 이 토큰이 속성 비교 연산자인가. + /// + /// `Star`(`*`)와 `Tilde`(`~`)가 여기 없는 것이 중요하다 — 둘은 각각 와일드카드· + /// 형제 결합자이고, 비교자로 쓰이는 형태는 `*=`·`~=` 라는 **다른 토큰**이다. + /// 최장 일치 렉싱이 그 구분을 이미 끝내 두었으므로 여기서 되짚을 필요가 없다. + pub fn is_compare_op(&self) -> bool { + matches!( + self, + Tok::Eq + | Tok::Ne + | Tok::Gt + | Tok::Lt + | Tok::Ge + | Tok::Le + | Tok::Prefix + | Tok::Suffix + | Tok::Substr + | Tok::Glob + ) + } +} + +impl fmt::Display for Tok { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.describe()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compare_ops_exclude_bare_star_and_tilde() { + assert!(Tok::Substr.is_compare_op()); + assert!(Tok::Glob.is_compare_op()); + assert!(!Tok::Star.is_compare_op()); + assert!(!Tok::Tilde.is_compare_op()); + } + + #[test] + fn describe_shows_the_value_not_just_the_kind() { + assert_eq!(Tok::Ident("para".into()).describe(), "식별자 `para`"); + assert_eq!(Tok::Int(-1).describe(), "정수 -1"); + assert_eq!(Tok::Ge.describe(), "`>=`"); + } +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000000..ffe59029a3 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,88 @@ +//! 에이전트 문서 조작 커널 — 선택자·연산 대수·앵커·검증·트랜잭션의 단일 척추. +//! +//! ## 왜 커널인가 — 지금의 편집 표면이 막는 것 +//! +//! 오늘 에이전트가 rhwp 로 문서를 고치는 길은 `edit` 하위 명령 6개가 전부다 +//! (`fill-fields`·`replace-text`·`set-cell`·`insert-image`·`redact`·`sanitize`). +//! 이 여섯은 각각 **자기만의 지목 문법**을 쓴다 — 셀은 `--table/--row/--col`, +//! 필드는 `--data 이름=값[k]`, 치환은 `--find/--occurrence`. 즉 "문서의 어디"를 +//! 가리키는 공통 개념이 없다. 그래서 다음 세 가지가 구조적으로 불가능하다. +//! +//! 1. **임의 지목** — "3절 두 번째 표의 마지막 행 전부"를 표현할 문법이 없다. +//! 여섯 명령이 미리 뚫어 둔 구멍 밖은 손댈 수 없다. +//! 2. **되돌리기** — 편집은 파일을 덮어쓸 뿐 역연산을 남기지 않는다. 에이전트가 +//! 한 단계 잘못 밟으면 복구 수단은 원본 사본뿐이고, 그건 도구가 아니라 운이다. +//! 3. **합성** — 여러 편집을 하나의 원자 단위로 묶을 수 없다. `run` 계획서가 +//! 순차 실행을 주지만 중간 실패 시 앞선 단계는 이미 적용된 채 남는다(#3905 가 +//! 실측한 경합과 같은 뿌리다). +//! +//! 여기에 하나 더 — 에이전트 축 모듈(`agent_profiles`·`policy_gate`·`anchor_log`· +//! `capsule_sign` …)은 전부 `main.rs` 의 `mod` 다. **바이너리 전용**이라 `bindings/ +//! Native`·`tools/*`·wasm 어느 쪽도 링크할 수 없다. ROADMAP 이 업스트림 책임으로 +//! 적은 "서비스 연계 표면 — MCP 서버와 공개 API 계약"이 정작 라이브러리에는 없다. +//! 다운스트림이 쓰려면 CLI 를 프로세스로 띄우는 수밖에 없고, 그건 계약이 아니라 +//! 우회로다. +//! +//! ## 척추 — 여덟 층이 서로를 필요로 하는 이유 +//! +//! ```text +//! DSEL 문서의 "어디"를 값으로 만든다 (dsel) +//! │ 선택 결과는 NodeId 집합 +//! ▼ +//! Op "무엇을"을 역연산 가능한 값으로 (op) +//! │ 연산은 자신이 건드릴 NodeId 를 선언한다 +//! ▼ +//! Anchor NodeId 를 문서 변경에 견디게 고정 (anchor) +//! │ 적용 후 재결속(rebase)까지가 한 단위 +//! ▼ +//! Verify 사전·사후조건과 예상 효과 (verify) +//! │ 효과 예측이 없으면 dry-run 은 추측이다 +//! ▼ +//! Txn 저널·원자 적용·롤백 (txn) +//! │ 역연산 스택이 곧 저널 +//! ▼ +//! Policy 연산별 능력 요구 ↔ 프로필 승인 (policy) +//! │ 연산이 자기 요구를 선언해야 게이트가 성립한다 +//! ▼ +//! Provenance 적용 이력의 계보·서명 (prov) +//! │ +//! ▼ +//! Manifest capabilities·MCP 도구 자동 산출 (manifest) +//! ``` +//! +//! 층 사이의 화살표는 편의가 아니라 **정의 의존**이다. 선택자 없이는 연산이 +//! 대상을 못 적고, 연산이 대상을 안 적으면 앵커가 무엇을 고정할지 모르고, 앵커가 +//! 없으면 사후조건은 이동한 위치를 오검사하고, 효과 예측이 없으면 저널이 역연산을 +//! 만들 수 없고, 역연산이 없으면 롤백이 없고, 연산이 능력을 선언하지 않으면 +//! 게이트가 항상-참이 되고, 이 전부가 없으면 매니페스트는 빈 목록이다. +//! +//! 그래서 이 모듈군은 조각으로 쓸 수 없다. 한 층만 떼면 그 층은 컴파일은 되어도 +//! 아무것도 하지 못한다 — 설계가 그렇게 생겼기 때문이지 일부러 얽어서가 아니다. +//! +//! ## 라이브러리에 두는 이유 +//! +//! `pub mod agent` 는 `lib.rs` 에 있다. CLI(`rhwp agent …`)는 이 커널의 **한 소비자** +//! 일 뿐이고, MCP 서버·네이티브 바인딩·wasm 도 같은 자격으로 같은 타입을 쓴다. +//! 표면마다 편집 의미가 갈라지는 사고(#3905 계열)는 의미를 한 곳에 두는 것으로만 +//! 막힌다. +//! +//! ## 결정론 +//! +//! 커널 전 구간은 결정론이다 — 같은 문서·같은 연산이면 같은 산출, 같은 해시. +//! 시각·난수·환경 변수는 커널 안에 들어오지 않는다(필요한 경우 호출자가 값으로 +//! 주입한다). 이는 `capsule_sign`·`anchor_log` 의 결정론 문화와 같은 규약이며, +//! 재실행 검증(`gate --deep`)이 성립하는 전제다. + +pub mod dsel; + +pub use dsel::{Selector, SelectorError}; + +/// 커널 계약 버전 — 선택자 문법·연산 대수·봉투 필드를 아우르는 단일 판번호. +/// +/// 봉투 `schemaVersion`(명령별)·`capabilitiesSchemaVersion`(명령 표면)과 **분리**된 +/// 축이다. 셋을 하나로 묶으면 커널 문법이 한 자 바뀔 때마다 명령 표면 계약까지 +/// major 를 올려야 하고, 그러면 아무 관계 없는 소비자가 전부 깨진다. +/// +/// 판올림 규칙: 문법·연산 **추가** = minor, 기존 문법의 의미 변경·삭제 = major. +/// major 는 분기 회고 승인 없이 금지한다(`capabilities` 정책과 동일). +pub const AGENT_KERNEL_VERSION: &str = "1.0"; diff --git a/src/lib.rs b/src/lib.rs index 899fab083a..17c6ec3321 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ use wasm_bindgen::prelude::*; +pub mod agent; pub mod capabilities_schema; pub mod diagnostics; pub mod doclang;