MIR: lexical scoping + builder vars modules

This commit is contained in:
nyash-codex
2025-12-13 01:30:04 +09:00
parent 5c75506dcc
commit 1fae4f1648
16 changed files with 388 additions and 29 deletions

View File

@ -0,0 +1,87 @@
# Phase 67: MIR Var Identity Survey + Shadowing Probe
## 目的
- MIR 側の「ブロックスコープ/シャドウイング」が現状どう実現されているかを確定する。
- JoinIR/Normalized の Ownership/Relay を BindingId束縛ID化すべきか、MIR 側のスコープ機構で足りるか判断する。
## 調査対象(読んだ場所)
- MIR ビルダー
- `src/mir/builder.rs``variable_map`, `build_variable_access`, `build_assignment`
- `src/mir/builder/stmts.rs``build_block`, `build_local_statement`
- `src/mir/builder/exprs.rs``ASTNode::Program` の lowering が `cf_block` に直結)
- JoinIR lowering
- `src/mir/join_ir/lowering/scope_manager.rs`(名前ベース lookup
- AST
- `src/ast.rs``ASTNode::ScopeBox` の意図コメント)
- 仕様doc
- `docs/reference/language/variables-and-scope.md`block scope + shadowing + assignment rule
- `docs/quick-reference/syntax-cheatsheet.md`(未宣言代入はエラー、と書いてある)
## 観測: 現状の MIR ビルダーは「名前→ValueId」1枚
### 1) “束縛”は BindingId ではなく name で追跡される
- `MirBuilder``variable_map: BTreeMap<String, ValueId>` を持つ(`src/mir/builder.rs:90`)。
- `build_variable_access``variable_map.get(name)` のみで解決し、無ければ即 `Undefined variable: <name>` エラー(`src/mir/builder.rs:533`)。
- `build_assignment` は「既存束縛の有無」を見ず、`variable_map.insert(name, value_id)` を行う(`src/mir/builder.rs:574`)。
結論: MIR/SSA の識別子解決は “name ベース上書き” であり、現状は BindingId束縛の同一性を持っていない。
### 2) ブロックは変数束縛のスコープフレームを作っていない
- `ASTNode::Program``cf_block` に入り、そのまま `build_block` を呼ぶ(`src/mir/builder/exprs.rs:20`, `src/mir/builder/control_flow/mod.rs:59`)。
- `build_block` は statements を順に lowering するだけで、`variable_map` の退避/復元を行わない(`src/mir/builder/stmts.rs:168`)。
- `local``build_local_statement``variable_map.insert(var_name, var_id)` を直で行う(`src/mir/builder/stmts.rs:281`)。
結論: 現状の MIR では「ブロックスコープ」は存在せず、`local` の再宣言は outer を恒久的に上書きする。
### 3) `ASTNode::ScopeBox` は “意味不変” のラッパーであり、束縛スコープではない
- `ASTNode::ScopeBox` は “診断/マクロ可視性のための no-op スコープ” と明記されている(`src/ast.rs:593`)。
## 仕様docの不一致
- `docs/reference/language/variables-and-scope.md` は:
- `local` は block-scoped`docs/reference/language/variables-and-scope.md:9`
- inner `local` は shadowing`docs/reference/language/variables-and-scope.md:11`
- 未宣言代入は “現在スコープに新規束縛を作る”(`docs/reference/language/variables-and-scope.md:22`
- `docs/quick-reference/syntax-cheatsheet.md` は:
- “未宣言名への代入はエラー” と書いてある(`docs/quick-reference/syntax-cheatsheet.md:13`, `docs/quick-reference/syntax-cheatsheet.md:204`
この Phase 67 では「実装現状」を SSOT 化し、どちらの仕様に合わせて修正するかは Phase 68 以降で決める(ただし、少なくとも
shadowing の有無は言語中枢なので放置できない)。
## プローブVM スモーク追加)
追加したスモーク:
- `tools/smokes/v2/profiles/quick/core/phase215/scope_shadow_vm.sh`
- 観測: `local x=1 { local x=2 } return x``rc=2`inner `local x` が outer を上書きして leak
- `tools/smokes/v2/profiles/quick/core/phase215/scope_assign_creates_local_vm.sh`
- 観測: `{ y = 42 } return y``rc=42`(束縛が block 外へ leak
再現コマンド:
- `bash tools/smokes/v2/profiles/quick/core/phase215/scope_shadow_vm.sh`
- `bash tools/smokes/v2/profiles/quick/core/phase215/scope_assign_creates_local_vm.sh`
## 結論と選択肢Phase 68 への分岐)
結論(現状):
- MIR ビルダーはブロックスコープ/シャドウイングを実装していない(関数スコープの `variable_map` 1枚
- JoinIR lowering 側も `ScopeManager::lookup(name)` の名前ベースであり、BindingId を前提にしていない(`src/mir/join_ir/lowering/scope_manager.rs:58`)。
選択肢:
- A) MIR にスコープフレームを入れてシャドウイングを MIR で解決
- Phase 67 のプローブが “仕様どおり” に通るようになるまで、MIR ビルダー側で `variable_map` をスコープスタック化するのが
直接的。
- B) JoinIR/Ownership を BindingId束縛IDSSOT にして、名前は表示専用に降格
- Ownership/Relay/ShapeGuard の解析精度は上がるが、言語全体の block scope は MIR 側で結局必要になるJoinIR だけ直しても
Stage-3 の一般コードは救えない)。
Phase 67 の観測de facto 実装から、Phase 68 はまず AMIR スコープフレーム)に進むのが安全。
その上で、Ownership/Relay が shadowing を正確に扱う必要が出た箇所だけを Phase 69+ で BindingId 化するのが最小差分になる。

View File

@ -21,7 +21,7 @@ Assignment to an identifier resolves as follows:
1) If a `local` declaration with the same name exists in the current block, update that binding.
2) Otherwise, search outward through enclosing blocks and update the first found binding.
3) If no binding exists in any enclosing scope, create a new binding in the current scope.
3) If no binding exists in any enclosing scope, it is an error (undeclared variable). Declare it with `local`.
This matches intuitive blockscoped semantics (Lualike), and differs from Python where inner blocks do not create a new scope (function scope), and assignment would create a local unless `nonlocal`/`global` is used.
@ -63,4 +63,3 @@ The above semantics are enforced consistently across:
- LLVM harness/EXE: parity tests validate identical exit codes/behavior.
See also: quick/integration smokes `scope_assign_vm.sh`, `vm_llvm_scope_assign.sh`.

View File

@ -590,8 +590,8 @@ pub enum ASTNode {
span: Span,
},
/// ScopeBoxオプション: 診断/マクロ可視性のためのno-opスコープ。
/// 正規化で注入され、MIRビルダがブロックとして処理(意味不変)。
/// ScopeBoxオプション: 正規化で注入される明示的なレキシカルスコープ境界
/// MIR ビルダは `{ ... }` と同様にブロックとして処理するlocal のシャドウイング/寿命を分離)。
ScopeBox { body: Vec<ASTNode>, span: Span },
/// Outbox変数宣言: outbox x, y, z (static関数内専用)

View File

@ -92,6 +92,12 @@ pub struct MirBuilder {
/// Phase 25.1: HashMap → BTreeMapPHI生成の決定性確保
pub(super) variable_map: BTreeMap<String, ValueId>,
/// Lexical scope stack for block-scoped `local` declarations.
///
/// This tracks per-block shadowing so `local x` inside `{...}` restores the
/// outer binding when the block ends.
lexical_scope_stack: Vec<vars::lexical_scope::LexicalScopeFrame>,
/// Pending phi functions to be inserted
#[allow(dead_code)]
pub(super) pending_phis: Vec<(BasicBlockId, ValueId, String)>,
@ -266,6 +272,7 @@ impl MirBuilder {
block_gen: BasicBlockIdGenerator::new(),
compilation_context: None, // 箱理論: デフォルトは従来モード
variable_map: BTreeMap::new(), // Phase 25.1: 決定性確保
lexical_scope_stack: Vec::new(),
pending_phis: Vec::new(),
value_origin_newbox: BTreeMap::new(), // Phase 25.1: 決定性確保
user_defined_boxes: HashSet::new(),
@ -546,6 +553,11 @@ impl MirBuilder {
if let Some(&value_id) = self.variable_map.get(&name) {
Ok(value_id)
} else {
Err(self.undefined_variable_message(&name))
}
}
pub(in crate::mir::builder) fn undefined_variable_message(&self, name: &str) -> String {
// Enhance diagnostics using Using simple registry (Phase 1)
let mut msg = format!("Undefined variable: {}", name);
@ -559,16 +571,14 @@ impl MirBuilder {
msg.push_str(&format!("\nHint: '{}' is a Stage-3 keyword. Prefer NYASH_FEATURES=stage3 (legacy: NYASH_PARSER_STAGE3=1 / HAKO_PARSER_STAGE3=1 for Stage-B).", name));
}
let suggest = crate::using::simple_registry::suggest_using_for_symbol(&name);
let suggest = crate::using::simple_registry::suggest_using_for_symbol(name);
if !suggest.is_empty() {
msg.push_str("\nHint: symbol appears in using module(s): ");
msg.push_str(&suggest.join(", "));
msg.push_str(
"\nConsider adding 'using <module> [as Alias]' or check nyash.toml [using].",
);
}
Err(msg)
msg.push_str("\nConsider adding 'using <module> [as Alias]' or check nyash.toml [using].");
}
msg
}
/// Build assignment
@ -577,6 +587,11 @@ impl MirBuilder {
var_name: String,
value: ASTNode,
) -> Result<ValueId, String> {
// SSOT (LANGUAGE_REFERENCE_2025 / syntax-cheatsheet):
// - Assignment to an undeclared name is an error.
// - Use `local name = ...` (or `local name; name = ...`) to declare.
vars::assignment_resolver::AssignmentResolverBox::ensure_declared(self, &var_name)?;
let value_id = self.build_expression(value)?;
// Step 5-5-E: FIX variable map corruption bug

View File

@ -21,6 +21,7 @@ impl super::MirBuilder {
// Sequentially lower statements and return last value (or Void)
self.cf_block(statements)
}
ASTNode::ScopeBox { body, .. } => self.cf_block(body),
ASTNode::Print { expression, .. } => self.build_print_statement(*expression),
ASTNode::If {
condition,

View File

@ -170,6 +170,8 @@ impl super::MirBuilder {
// Scope hint for bare block (Program)
let scope_id = self.current_block.map(|bb| bb.as_u32()).unwrap_or(0);
self.hint_scope_enter(scope_id);
let _lex_scope = super::vars::lexical_scope::LexicalScopeGuard::new(self);
let mut last_value = None;
let total = statements.len();
eprintln!("[DEBUG/build_block] Processing {} statements", total);
@ -336,7 +338,7 @@ impl super::MirBuilder {
var_name, var_id
);
}
self.variable_map.insert(var_name.clone(), var_id);
self.declare_local_in_current_scope(var_name, var_id)?;
// SlotRegistry にもローカル変数スロットを登録しておくよ(観測専用)
if let Some(reg) = self.current_slot_registry.as_mut() {
let ty = self.value_types.get(&var_id).cloned();

View File

@ -0,0 +1,26 @@
# `mir::builder::vars`(変数/スコープ系の小箱)
このディレクトリは「MIR ビルダ内の変数・スコープ」に関する小さな責務を分離するための層だよ。
## 責務(この層がやる)
- **レキシカルスコープ**: `{...}` / `ScopeBox` の境界で `local` のシャドウイングを復元する。
- **AST 走査ユーティリティ**: free vars 収集など、純粋な走査処理。
- **代入の宣言ポリシー**: 未宣言名への代入を Fail-Fast にする(`AssignmentResolverBox`)。
## 非責務(この層がやらない)
- JoinIR lowering 側の名前解決(`join_ir/lowering/*``ScopeManager` が担当)。
- ループパターン/PHI/境界生成(`control_flow/joinir/*` が担当)。
- 言語仕様の追加(この層は既存仕様の実装に限定)。
## スコープ/名前解決の境界SSOT
同じ「名前」を扱っていても、層ごとに “解いている問題” が違うので混ぜない。
- **MIRこの層**: `variable_map` + `LexicalScopeGuard` で「束縛の寿命・シャドウイング」を管理するSSA 変換のため)。
- **JoinIR lowering**: `src/mir/join_ir/lowering/scope_manager.rs` は JoinIR 内の `name → ValueId` を解決する。
- `ExprLowerer`**ScopeManager 経由のみ** で名前解決するenv を直参照しない)。
- **解析箱**: `LoopConditionScopeBox` / `LoopBodyLocalEnv` は「禁止/許可」「スケジュール」などの補助情報で、束縛そのものではない。
この境界を跨ぐ “便利メソッド” を作るのは原則禁止(責務混線の温床)。

View File

@ -0,0 +1,29 @@
use super::super::MirBuilder;
/// AssignmentResolverBox
///
/// Responsibility:
/// - Enforce "explicit local declaration" policy for assignments.
/// - Produce consistent diagnostics shared with variable access errors.
pub(in crate::mir::builder) struct AssignmentResolverBox;
impl AssignmentResolverBox {
pub(in crate::mir::builder) fn ensure_declared(
builder: &MirBuilder,
var_name: &str,
) -> Result<(), String> {
// Compiler-generated temporaries are not part of the user variable namespace.
if var_name.starts_with("__pin$") {
return Ok(());
}
if builder.variable_map.contains_key(var_name) {
return Ok(());
}
let mut msg = builder.undefined_variable_message(var_name);
msg.push_str("\nHint: Nyash requires explicit local declaration. Use `local <name>` before assignment.");
Err(msg)
}
}

View File

@ -4,7 +4,7 @@ use std::collections::HashSet;
/// Collect free variables used in `node` into `used`, excluding names present in `locals`.
/// `locals` is updated as new local declarations are encountered.
#[allow(dead_code)]
pub(super) fn collect_free_vars(
pub(in crate::mir::builder) fn collect_free_vars(
node: &ASTNode,
used: &mut HashSet<String>,
locals: &mut HashSet<String>,

View File

@ -0,0 +1,75 @@
use crate::mir::ValueId;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Default)]
pub(in crate::mir::builder) struct LexicalScopeFrame {
declared: BTreeSet<String>,
restore: BTreeMap<String, Option<ValueId>>,
}
impl LexicalScopeFrame {
fn new() -> Self {
Self::default()
}
}
pub(in crate::mir::builder) struct LexicalScopeGuard {
builder: *mut super::super::MirBuilder,
}
impl LexicalScopeGuard {
pub(in crate::mir::builder) fn new(builder: &mut super::super::MirBuilder) -> Self {
builder.push_lexical_scope();
Self { builder }
}
}
impl Drop for LexicalScopeGuard {
fn drop(&mut self) {
// Safety: LexicalScopeGuard is created from a unique `&mut MirBuilder` and its lifetime
// is bounded by the surrounding lexical scope. Drop runs at most once.
unsafe { &mut *self.builder }.pop_lexical_scope();
}
}
impl super::super::MirBuilder {
pub(in crate::mir::builder) fn push_lexical_scope(&mut self) {
self.lexical_scope_stack.push(LexicalScopeFrame::new());
}
pub(in crate::mir::builder) fn pop_lexical_scope(&mut self) {
let frame = self
.lexical_scope_stack
.pop()
.expect("COMPILER BUG: pop_lexical_scope without push_lexical_scope");
for (name, previous) in frame.restore {
match previous {
Some(prev_id) => {
self.variable_map.insert(name, prev_id);
}
None => {
self.variable_map.remove(&name);
}
}
}
}
pub(in crate::mir::builder) fn declare_local_in_current_scope(
&mut self,
name: &str,
value: ValueId,
) -> Result<(), String> {
let Some(frame) = self.lexical_scope_stack.last_mut() else {
return Err("COMPILER BUG: local declaration outside lexical scope".to_string());
};
if frame.declared.insert(name.to_string()) {
let previous = self.variable_map.get(name).copied();
frame.restore.insert(name.to_string(), previous);
}
self.variable_map.insert(name.to_string(), value);
Ok(())
}
}

View File

@ -0,0 +1,3 @@
pub(super) mod assignment_resolver;
pub(super) mod free_vars;
pub(super) mod lexical_scope;

View File

@ -7,4 +7,13 @@
- ConditionEnv は「条件で参照する JoinIR ValueId だけ」を持つ。body-local を直接入れず、必要なら昇格ScopeManager に解決を任せる。
- Fail-Fast 原則: Unsupported/NotFound は明示エラーにして、by-name ヒューリスティックや静かなフォールバックは禁止。
## 名前解決の境界SSOT
このディレクトリの `ScopeManager` は「JoinIR lowering の中で」名前を `ValueId` に解決するための箱だよ。
同じ “名前” でも、MIR 側の束縛寿命とは問題が違うので混ぜない。
- **MIRSSA/束縛寿命)**: `src/mir/builder/vars/*``{...}` のレキシカルスコープと `local` のシャドウイングを管理する。
- **JoinIR loweringこの層**: `ScopeManager``ConditionEnv/LoopBodyLocalEnv/CapturedEnv/CarrierInfo` を束ねて解決順序を固定する。
- **解析箱**: `LoopConditionScopeBox` は「条件が参照して良いスコープか」を判定する箱で、名前解決そのものはしない。
詳しい境界ルールは `docs/development/current/main/phase238-exprlowerer-scope-boundaries.md` を参照してね。***

View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../../../../../.." && pwd)"
source "$ROOT_DIR/tools/smokes/v2/lib/test_runner.sh" || true
source "$ROOT_DIR/tools/smokes/v2/lib/result_checker.sh" || true
require_env || { echo "[SKIP] env not ready"; exit 0; }
test_scope_assign_creates_local_vm() {
local code='static box Main { method main(args) { { if (1==1) { y = 42 } } return y } }'
local tmp
tmp=$(mktemp --suffix .hako)
printf '%s' "$code" > "$tmp"
# Spec (LANGUAGE_REFERENCE_2025 / syntax-cheatsheet):
# - Undeclared assignment is an error (must use `local y`).
local out
out=$(NYASH_FEATURES=stage3 "$NYASH_BIN" --backend vm "$tmp" 2>&1)
local rc=$?
if [[ "$rc" -eq 0 ]]; then
echo "[FAIL] scope_assign_creates_local_vm: expected failure, got rc=0"
rm -f "$tmp" 2>/dev/null || true
return 1
fi
if ! printf '%s' "$out" | grep -q "Undefined variable: y"; then
echo "[FAIL] scope_assign_creates_local_vm: missing 'Undefined variable: y' in output"
echo "$out"
rm -f "$tmp" 2>/dev/null || true
return 1
fi
echo "[PASS] scope_assign_creates_local_vm"
rm -f "$tmp" 2>/dev/null || true
return 0
}
run_test "scope_assign_creates_local_vm" test_scope_assign_creates_local_vm

View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../../../../../.." && pwd)"
source "$ROOT_DIR/tools/smokes/v2/lib/test_runner.sh" || true
source "$ROOT_DIR/tools/smokes/v2/lib/result_checker.sh" || true
require_env || { echo "[SKIP] env not ready"; exit 0; }
test_scope_loop_body_local_vm() {
local code='static box Main { method main(args) { local i = 0 local sum = 0 loop(i < 10) { local y = 7 sum = sum + y if i == 1 { break } i = i + 1 } return y } }'
local tmp
tmp=$(mktemp --suffix .hako)
printf '%s' "$code" > "$tmp"
# Spec: loop body is a block; `local y` must not leak outside the loop.
local out
out=$(NYASH_FEATURES=stage3 "$NYASH_BIN" --backend vm "$tmp" 2>&1)
local rc=$?
if [[ "$rc" -eq 0 ]]; then
echo "[FAIL] scope_loop_body_local_vm: expected failure, got rc=0"
rm -f "$tmp" 2>/dev/null || true
return 1
fi
if ! printf '%s' "$out" | grep -q "Undefined variable: y"; then
echo "[FAIL] scope_loop_body_local_vm: missing 'Undefined variable: y' in output"
echo "$out"
rm -f "$tmp" 2>/dev/null || true
return 1
fi
echo "[PASS] scope_loop_body_local_vm"
rm -f "$tmp" 2>/dev/null || true
return 0
}
run_test "scope_loop_body_local_vm" test_scope_loop_body_local_vm

View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../../../../../../.." && pwd)"
source "$ROOT_DIR/tools/smokes/v2/lib/test_runner.sh" || true
source "$ROOT_DIR/tools/smokes/v2/lib/result_checker.sh" || true
require_env || { echo "[SKIP] env not ready"; exit 0; }
test_scope_shadow_vm() {
local code='static box Main { method main(args) { local x = 1 { local x = 2 } return x } }'
local tmp
tmp=$(mktemp --suffix .hako)
printf '%s' "$code" > "$tmp"
# Spec (LANGUAGE_REFERENCE_2025 / syntax-cheatsheet):
# `local` is block-scoped and supports shadowing.
# The inner `local x` must not overwrite the outer binding.
local rc
NYASH_FEATURES=stage3 "$NYASH_BIN" --backend vm "$tmp" >/dev/null 2>&1
rc=$?
if [[ "$rc" -ne 1 ]]; then
echo "[FAIL] scope_shadow_vm: vm rc=$rc (expected 1)"
rm -f "$tmp" 2>/dev/null || true
return 1
fi
echo "[PASS] scope_shadow_vm"
rm -f "$tmp" 2>/dev/null || true
return 0
}
run_test "scope_shadow_vm" test_scope_shadow_vm

View File

@ -263,7 +263,9 @@ find_test_files() {
test_files+=("$file")
done < <(find "$profile_dir" -name "*.sh" -type f -print0)
if [ ${#test_files[@]} -gt 0 ]; then
printf '%s\n' "${test_files[@]}"
fi
}
# 単一テスト実行