diff --git a/apps/tests/phase263_pattern2_seg_realworld_min.hako b/apps/tests/phase263_pattern2_seg_realworld_min.hako
new file mode 100644
index 00000000..98864f71
--- /dev/null
+++ b/apps/tests/phase263_pattern2_seg_realworld_min.hako
@@ -0,0 +1,34 @@
+// Phase 29ab P4 / Phase 263: Pattern2 seg real-world minimal repro
+//
+// Goal:
+// - seg is LoopBodyLocal and reassigned in the loop body
+// - break condition depends on seg
+// - current behavior: JoinIR freeze (read-only contract violation)
+// - after fix: prints "4" and returns 4
+
+static box Main {
+ main() {
+ local table = "a|||"
+ local i = 0
+
+ loop(true) {
+ local j = table.indexOf("|||", i)
+ local seg = ""
+
+ if j >= 0 {
+ seg = table.substring(i, j)
+ } else {
+ seg = table.substring(i, table.length())
+ }
+
+ if seg == "" {
+ break
+ }
+
+ i = j + 3
+ }
+
+ print(i)
+ return i
+ }
+}
diff --git a/docs/development/current/main/phases/phase-263/README.md b/docs/development/current/main/phases/phase-263/README.md
index b7a89d0c..bb7cf5bb 100644
--- a/docs/development/current/main/phases/phase-263/README.md
+++ b/docs/development/current/main/phases/phase-263/README.md
@@ -215,6 +215,39 @@ git revert 93022e7e1
- **方針**: Pattern2 scope 内で完結、SSOT 維持、既定挙動不変
- **Fail-Fast 原則**: 対象外は Ok(None) で後続経路へ、対象だが未対応は Err で即座に失敗(silent skip 禁止)
+---
+
+# Phase 29ab P4: Stage‑B 実ログ seg(Derived vs Promote 決定)
+
+## Decision (SSOT)
+
+**A: Derived slot** を採用する。
+
+理由:
+- `seg` は loop body で再代入されるため、read-only promotion は原理的に不成立。
+- Stage‑B 実ログの形は「body 内で seg を再計算 → break で参照」であり、毎イテレーション再計算の Derived が素直。
+- 既存の Pattern2 の構造(BodyInit → Break)と `LoopBodyLocalEnv` に収まる。
+
+## Derived slot contract (minimal)
+
+- 対象は **Pattern2 break 条件で参照される LoopBodyLocal 1 変数**。
+- ループ body に以下の最小形があること:
+ 1. `local seg = ` が top-level に存在
+ 2. `if { seg = } else { seg = }` が top-level に存在
+ 3. break guard より前に 1) と 2) がある
+- `seg` への代入は上記 if/else のみ(他の代入がある場合は out-of-scope)
+- 代入式は **純粋**(MethodCall/Literal/Variable)のみ
+
+## Fixtures / Smokes
+
+- `apps/tests/phase263_pattern2_seg_realworld_min.hako` (Stage‑B 実ログ最小化)
+- `tools/smokes/v2/profiles/integration/apps/phase263_pattern2_seg_realworld_min_vm.sh`
+
+### Smoke switch rule
+
+- **Before Derived slot**: freeze を PASS(`[joinir/freeze]` を期待)
+- **After Derived slot**: `print/return = 4` を PASS に切り替える
+
## Related Documentation
- **Plan file**: `/home/tomoaki/.claude/plans/eventual-mapping-lemon.md`
diff --git a/src/backend/mir_interpreter/handlers/boxes_plugin.rs b/src/backend/mir_interpreter/handlers/boxes_plugin.rs
index 11279786..91bf332e 100644
--- a/src/backend/mir_interpreter/handlers/boxes_plugin.rs
+++ b/src/backend/mir_interpreter/handlers/boxes_plugin.rs
@@ -1,4 +1,7 @@
use super::*;
+use super::string_method_helpers::{
+ parse_index_of_args, parse_last_index_of_args, ArgParsePolicy,
+};
use crate::box_trait::NyashBox;
pub(super) fn invoke_plugin_box(
@@ -62,26 +65,31 @@ pub(super) fn invoke_plugin_box(
let s = s_box.value;
match method {
"lastIndexOf" => {
- if let Some(arg_id) = args.get(0) {
- let needle = this.reg_load(*arg_id)?.to_string();
- let helper = crate::boxes::string_box::StringBox::new(s);
- let result_box = helper.lastIndexOf(&needle);
- this.write_from_box(dst, result_box);
- Ok(())
- } else {
- Err(this.err_invalid("lastIndexOf requires 1 argument"))
- }
+ let needle = parse_last_index_of_args(
+ this,
+ args,
+ ArgParsePolicy::STRICT,
+ "lastIndexOf requires 1 argument",
+ )?;
+ let helper = crate::boxes::string_box::StringBox::new(s);
+ let result_box = helper.lastIndexOf(&needle);
+ this.write_from_box(dst, result_box);
+ Ok(())
}
"indexOf" | "find" => {
- if let Some(arg_id) = args.get(0) {
- let needle = this.reg_load(*arg_id)?.to_string();
- let helper = crate::boxes::string_box::StringBox::new(s);
- let result_box = helper.find(&needle);
- this.write_from_box(dst, result_box);
- Ok(())
- } else {
- Err(this.err_invalid("indexOf/find requires 1 argument"))
- }
+ let helper = crate::boxes::string_box::StringBox::new(s);
+ let (needle, start) = parse_index_of_args(
+ this,
+ args,
+ ArgParsePolicy::STRICT,
+ "indexOf/find requires 1 or 2 arguments",
+ )?;
+ let result_box = match start {
+ Some(start) => helper.find_from(&needle, start),
+ None => helper.find(&needle),
+ };
+ this.write_from_box(dst, result_box);
+ Ok(())
}
// Phase 25.1m: minimal builtin support for StringBox.is_space(ch)
"is_space" => {
diff --git a/src/backend/mir_interpreter/handlers/boxes_string.rs b/src/backend/mir_interpreter/handlers/boxes_string.rs
index 1775008a..cd4767b7 100644
--- a/src/backend/mir_interpreter/handlers/boxes_string.rs
+++ b/src/backend/mir_interpreter/handlers/boxes_string.rs
@@ -1,4 +1,8 @@
use super::*;
+use super::string_method_helpers::{
+ parse_index_of_args, parse_last_index_of_args, parse_substring_args, ArgParsePolicy,
+};
+use crate::boxes::string_ops;
pub(super) fn try_handle_string_box(
this: &mut MirInterpreter,
@@ -90,38 +94,14 @@ pub(super) fn try_handle_string_box(
return Ok(true);
}
"indexOf" => {
- // Support both 1-arg indexOf(search) and 2-arg indexOf(search, fromIndex)
- let (needle, from_index) = match args.len() {
- 1 => {
- // indexOf(search) - search from beginning
- let n = this.reg_load(args[0])?.to_string();
- (n, 0)
- }
- 2 => {
- // indexOf(search, fromIndex) - search from specified position
- let n = this.reg_load(args[0])?.to_string();
- let from = this.reg_load(args[1])?.as_integer().unwrap_or(0);
- (n, from.max(0) as usize)
- }
- _ => {
- return Err(
- this.err_invalid("indexOf expects 1 or 2 args (search [, fromIndex])")
- );
- }
- };
-
- // Search for needle starting from from_index
- let search_str = if from_index >= sb_norm.value.len() {
- ""
- } else {
- &sb_norm.value[from_index..]
- };
-
- let idx = search_str
- .find(&needle)
- .map(|i| (from_index + i) as i64)
- .unwrap_or(-1);
-
+ let (needle, start) = parse_index_of_args(
+ this,
+ args,
+ ArgParsePolicy::STRICT,
+ "indexOf expects 1 or 2 args (search [, fromIndex])",
+ )?;
+ let mode = string_ops::index_mode_from_env();
+ let idx = string_ops::index_of(&sb_norm.value, &needle, start, mode);
this.write_result(dst, VMValue::Integer(idx));
return Ok(true);
}
@@ -135,10 +115,14 @@ pub(super) fn try_handle_string_box(
return Ok(true);
}
"lastIndexOf" => {
- // lastIndexOf(substr) -> last index or -1
- this.validate_args_exact("lastIndexOf", args, 1)?;
- let needle = this.reg_load(args[0])?.to_string();
- let idx = sb_norm.value.rfind(&needle).map(|i| i as i64).unwrap_or(-1);
+ let needle = parse_last_index_of_args(
+ this,
+ args,
+ ArgParsePolicy::STRICT,
+ "lastIndexOf requires 1 argument",
+ )?;
+ let mode = string_ops::index_mode_from_env();
+ let idx = string_ops::last_index_of(&sb_norm.value, &needle, mode);
this.write_result(dst, VMValue::Integer(idx));
return Ok(true);
}
@@ -165,29 +149,14 @@ pub(super) fn try_handle_string_box(
return Ok(true);
}
"substring" => {
- // Support both 1-arg (start to end) and 2-arg (start, end) forms
- let (s_idx, e_idx) = match args.len() {
- 1 => {
- // substring(start) - from start to end of string
- let s = this.reg_load(args[0])?.as_integer().unwrap_or(0);
- let len = sb_norm.value.chars().count() as i64;
- (s, len)
- }
- 2 => {
- // substring(start, end) - half-open interval [start, end)
- let s = this.reg_load(args[0])?.as_integer().unwrap_or(0);
- let e = this.reg_load(args[1])?.as_integer().unwrap_or(0);
- (s, e)
- }
- _ => {
- return Err(this.err_invalid("substring expects 1 or 2 args (start [, end])"));
- }
- };
- let len = sb_norm.value.chars().count() as i64;
- let start = s_idx.max(0).min(len) as usize;
- let end = e_idx.max(start as i64).min(len) as usize;
- let chars: Vec = sb_norm.value.chars().collect();
- let sub: String = chars[start..end].iter().collect();
+ let (start, end) = parse_substring_args(
+ this,
+ args,
+ ArgParsePolicy::STRICT,
+ "substring expects 1 or 2 args (start [, end])",
+ )?;
+ let mode = string_ops::index_mode_from_env();
+ let sub = string_ops::substring(&sb_norm.value, start, end, mode);
this.write_result(
dst,
VMValue::from_nyash_box(Box::new(crate::box_trait::StringBox::new(sub))),
diff --git a/src/backend/mir_interpreter/handlers/calls/method.rs b/src/backend/mir_interpreter/handlers/calls/method.rs
index 22156024..94afffdb 100644
--- a/src/backend/mir_interpreter/handlers/calls/method.rs
+++ b/src/backend/mir_interpreter/handlers/calls/method.rs
@@ -1,4 +1,8 @@
use super::*;
+use crate::backend::mir_interpreter::handlers::string_method_helpers::{
+ parse_index_of_args, parse_last_index_of_args, parse_substring_args, ArgParsePolicy,
+};
+use crate::boxes::string_ops;
impl MirInterpreter {
pub(super) fn execute_method_callee(
@@ -205,13 +209,15 @@ impl MirInterpreter {
("String", 303) => {
// indexOf
if let VMValue::String(s) = receiver {
- if let Some(arg_id) = args.get(0) {
- let needle = self.reg_load(*arg_id)?.to_string();
- let idx = s.find(&needle).map(|i| i as i64).unwrap_or(-1);
- Ok(VMValue::Integer(idx))
- } else {
- Err(self.err_invalid("String.indexOf: requires 1 argument"))
- }
+ let (needle, start) = parse_index_of_args(
+ self,
+ args,
+ ArgParsePolicy::LENIENT,
+ "String.indexOf: requires 1 argument",
+ )?;
+ let mode = string_ops::index_mode_from_env();
+ let idx = string_ops::index_of(s, &needle, start, mode);
+ Ok(VMValue::Integer(idx))
} else {
Err(self.err_invalid("String.indexOf: invalid receiver"))
}
@@ -219,13 +225,15 @@ impl MirInterpreter {
("String", 308) => {
// lastIndexOf
if let VMValue::String(s) = receiver {
- if let Some(arg_id) = args.get(0) {
- let needle = self.reg_load(*arg_id)?.to_string();
- let idx = s.rfind(&needle).map(|i| i as i64).unwrap_or(-1);
- Ok(VMValue::Integer(idx))
- } else {
- Err(self.err_invalid("String.lastIndexOf: requires 1 argument"))
- }
+ let needle = parse_last_index_of_args(
+ self,
+ args,
+ ArgParsePolicy::LENIENT,
+ "String.lastIndexOf: requires 1 argument",
+ )?;
+ let mode = string_ops::index_mode_from_env();
+ let idx = string_ops::last_index_of(s, &needle, mode);
+ Ok(VMValue::Integer(idx))
} else {
Err(self.err_invalid("String.lastIndexOf: invalid receiver"))
}
@@ -233,24 +241,14 @@ impl MirInterpreter {
("String", 301) => {
// substring
if let VMValue::String(s) = receiver {
- let start = if let Some(a0) = args.get(0) {
- self.reg_load(*a0)?.as_integer().unwrap_or(0)
- } else {
- 0
- };
- let end = if let Some(a1) = args.get(1) {
- self.reg_load(*a1)?.as_integer().unwrap_or(s.len() as i64)
- } else {
- s.len() as i64
- };
- let len = s.len() as i64;
- let i0 = start.max(0).min(len) as usize;
- let i1 = end.max(0).min(len) as usize;
- if i0 > i1 {
- return Ok(VMValue::String(String::new()));
- }
- let bytes = s.as_bytes();
- let sub = String::from_utf8(bytes[i0..i1].to_vec()).unwrap_or_default();
+ let (start, end) = parse_substring_args(
+ self,
+ args,
+ ArgParsePolicy::LENIENT,
+ "String.substring: requires 1 or 2 arguments",
+ )?;
+ let mode = string_ops::index_mode_from_env();
+ let sub = string_ops::substring(s, start, end, mode);
Ok(VMValue::String(sub))
} else {
Err(self.err_invalid("String.substring: invalid receiver"))
@@ -390,12 +388,15 @@ impl MirInterpreter {
if let VMValue::BoxRef(bx) = receiver {
let s_box = bx.to_string_box();
let s = s_box.value;
- if let Some(arg_id) = args.get(0) {
- let needle = self.reg_load(*arg_id)?.to_string();
- let helper = crate::boxes::string_box::StringBox::new(s);
- let result_box = helper.lastIndexOf(&needle);
- return Ok(VMValue::from_nyash_box(result_box));
- }
+ let needle = parse_last_index_of_args(
+ self,
+ args,
+ ArgParsePolicy::STRICT,
+ "StringBox.lastIndexOf: requires 1 argument",
+ )?;
+ let helper = crate::boxes::string_box::StringBox::new(s);
+ let result_box = helper.lastIndexOf(&needle);
+ return Ok(VMValue::from_nyash_box(result_box));
}
Err(self.err_invalid("StringBox.lastIndexOf: requires 1 argument"))
}
@@ -404,14 +405,20 @@ impl MirInterpreter {
if let VMValue::BoxRef(bx) = receiver {
let s_box = bx.to_string_box();
let s = s_box.value;
- if let Some(arg_id) = args.get(0) {
- let needle = self.reg_load(*arg_id)?.to_string();
- let helper = crate::boxes::string_box::StringBox::new(s);
- let result_box = helper.find(&needle);
- return Ok(VMValue::from_nyash_box(result_box));
- }
+ let helper = crate::boxes::string_box::StringBox::new(s);
+ let (needle, start) = parse_index_of_args(
+ self,
+ args,
+ ArgParsePolicy::STRICT,
+ "StringBox.indexOf: requires 1 or 2 arguments",
+ )?;
+ let result_box = match start {
+ Some(start) => helper.find_from(&needle, start),
+ None => helper.find(&needle),
+ };
+ return Ok(VMValue::from_nyash_box(result_box));
}
- Err(self.err_invalid("StringBox.indexOf: requires 1 argument"))
+ Err(self.err_invalid("StringBox.indexOf: requires 1 or 2 arguments"))
}
// Plugin Box methods (slot >= 1000)
diff --git a/src/backend/mir_interpreter/handlers/mod.rs b/src/backend/mir_interpreter/handlers/mod.rs
index 1717d01d..681d741e 100644
--- a/src/backend/mir_interpreter/handlers/mod.rs
+++ b/src/backend/mir_interpreter/handlers/mod.rs
@@ -24,6 +24,7 @@ mod externals;
mod lifecycle;
mod memory;
mod misc;
+mod string_method_helpers;
mod type_ops;
mod weak; // Phase 285A0: WeakRef handlers
diff --git a/src/backend/mir_interpreter/handlers/string_method_helpers.rs b/src/backend/mir_interpreter/handlers/string_method_helpers.rs
new file mode 100644
index 00000000..85c2d1d7
--- /dev/null
+++ b/src/backend/mir_interpreter/handlers/string_method_helpers.rs
@@ -0,0 +1,86 @@
+use super::*;
+
+#[derive(Debug, Clone, Copy)]
+pub(super) struct ArgParsePolicy {
+ pub allow_empty: bool,
+ pub allow_extra: bool,
+}
+
+impl ArgParsePolicy {
+ pub const STRICT: Self = Self {
+ allow_empty: false,
+ allow_extra: false,
+ };
+ pub const LENIENT: Self = Self {
+ allow_empty: true,
+ allow_extra: true,
+ };
+}
+
+pub(super) fn parse_index_of_args(
+ this: &mut MirInterpreter,
+ args: &[ValueId],
+ policy: ArgParsePolicy,
+ err_label: &str,
+) -> Result<(String, Option), VMError> {
+ if args.is_empty() {
+ return Err(this.err_invalid(err_label));
+ }
+ let needle = this.reg_load(args[0])?.to_string();
+ let start = if args.len() >= 2 {
+ Some(this.reg_load(args[1])?.as_integer().unwrap_or(0))
+ } else {
+ None
+ };
+ if !policy.allow_extra && args.len() > 2 {
+ return Err(this.err_invalid(err_label));
+ }
+ Ok((needle, start))
+}
+
+pub(super) fn parse_last_index_of_args(
+ this: &mut MirInterpreter,
+ args: &[ValueId],
+ policy: ArgParsePolicy,
+ err_label: &str,
+) -> Result {
+ if args.is_empty() {
+ return Err(this.err_invalid(err_label));
+ }
+ if !policy.allow_extra && args.len() > 1 {
+ return Err(this.err_invalid(err_label));
+ }
+ Ok(this.reg_load(args[0])?.to_string())
+}
+
+pub(super) fn parse_substring_args(
+ this: &mut MirInterpreter,
+ args: &[ValueId],
+ policy: ArgParsePolicy,
+ err_label: &str,
+) -> Result<(i64, Option), VMError> {
+ match args.len() {
+ 0 => {
+ if policy.allow_empty {
+ Ok((0, None))
+ } else {
+ Err(this.err_invalid(err_label))
+ }
+ }
+ 1 => Ok((this.reg_load(args[0])?.as_integer().unwrap_or(0), None)),
+ 2 => Ok((
+ this.reg_load(args[0])?.as_integer().unwrap_or(0),
+ Some(this.reg_load(args[1])?.as_integer().unwrap_or(0)),
+ )),
+ _ => {
+ if policy.allow_extra {
+ Ok((
+ this.reg_load(args[0])?.as_integer().unwrap_or(0),
+ Some(this.reg_load(args[1])?.as_integer().unwrap_or(0)),
+ ))
+ } else {
+ Err(this.err_invalid(err_label))
+ }
+ }
+ }
+}
diff --git a/src/boxes/basic/string_box.rs b/src/boxes/basic/string_box.rs
index 105e800a..3e5670fb 100644
--- a/src/boxes/basic/string_box.rs
+++ b/src/boxes/basic/string_box.rs
@@ -41,10 +41,9 @@ impl StringBox {
/// Find substring and return position (or -1 if not found)
pub fn find(&self, search: &str) -> Box {
use crate::box_trait::IntegerBox;
- match self.value.find(search) {
- Some(pos) => Box::new(IntegerBox::new(pos as i64)),
- None => Box::new(IntegerBox::new(-1)),
- }
+ let mode = crate::boxes::string_ops::index_mode_from_env();
+ let idx = crate::boxes::string_ops::index_of(&self.value, search, None, mode);
+ Box::new(IntegerBox::new(idx))
}
/// Replace all occurrences of old with new
diff --git a/src/boxes/mod.rs b/src/boxes/mod.rs
index e6c6133b..27315ee0 100644
--- a/src/boxes/mod.rs
+++ b/src/boxes/mod.rs
@@ -65,6 +65,7 @@ pub mod integer_box;
pub mod math_box;
pub mod random_box;
pub mod string_box;
+pub mod string_ops;
pub mod time_box;
// These boxes use web APIs that require special handling in WASM
pub mod aot_compiler_box;
diff --git a/src/boxes/string_box.rs b/src/boxes/string_box.rs
index 5653e97c..d278e8a0 100644
--- a/src/boxes/string_box.rs
+++ b/src/boxes/string_box.rs
@@ -13,6 +13,7 @@
* - `toLowerCase()` - 小文字変換
* - `trim()` - 前後の空白除去
* - `indexOf(search)` - 文字列検索
+ * - `indexOf(search, fromIndex)` - 指定位置から検索
* - `replace(from, to)` - 文字列置換
* - `charAt(index)` - 指定位置の文字取得
*
@@ -71,18 +72,18 @@ impl StringBox {
/// Env gate: NYASH_STR_CP=1 → return codepoint index; default is byte index
pub fn find(&self, search: &str) -> Box {
use crate::boxes::integer_box::IntegerBox;
- match self.value.find(search) {
- Some(byte_pos) => {
- let use_cp = std::env::var("NYASH_STR_CP").ok().as_deref() == Some("1");
- let idx = if use_cp {
- self.value[..byte_pos].chars().count() as i64
- } else {
- byte_pos as i64
- };
- Box::new(IntegerBox::new(idx))
- }
- None => Box::new(IntegerBox::new(-1)),
- }
+ let mode = crate::boxes::string_ops::index_mode_from_env();
+ let idx = crate::boxes::string_ops::index_of(&self.value, search, None, mode);
+ Box::new(IntegerBox::new(idx))
+ }
+
+ /// Find substring starting from a given index (or -1 if not found)
+ /// Env gate: NYASH_STR_CP=1 → indices are codepoint-based; default is byte index
+ pub fn find_from(&self, search: &str, start: i64) -> Box {
+ use crate::boxes::integer_box::IntegerBox;
+ let mode = crate::boxes::string_ops::index_mode_from_env();
+ let idx = crate::boxes::string_ops::index_of(&self.value, search, Some(start), mode);
+ Box::new(IntegerBox::new(idx))
}
/// Replace all occurrences of old with new
@@ -94,18 +95,9 @@ impl StringBox {
/// Env gate: NYASH_STR_CP=1 → return codepoint index; default is byte index.
pub fn lastIndexOf(&self, search: &str) -> Box {
use crate::boxes::integer_box::IntegerBox;
- match self.value.rfind(search) {
- Some(byte_pos) => {
- let use_cp = std::env::var("NYASH_STR_CP").ok().as_deref() == Some("1");
- let idx = if use_cp {
- self.value[..byte_pos].chars().count() as i64
- } else {
- byte_pos as i64
- };
- Box::new(IntegerBox::new(idx))
- }
- None => Box::new(IntegerBox::new(-1)),
- }
+ let mode = crate::boxes::string_ops::index_mode_from_env();
+ let idx = crate::boxes::string_ops::last_index_of(&self.value, search, mode);
+ Box::new(IntegerBox::new(idx))
}
/// Trim whitespace from both ends
diff --git a/src/boxes/string_ops.rs b/src/boxes/string_ops.rs
new file mode 100644
index 00000000..f0eac34c
--- /dev/null
+++ b/src/boxes/string_ops.rs
@@ -0,0 +1,101 @@
+//! Shared string indexing helpers (byte vs codepoint).
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StringIndexMode {
+ Byte,
+ CodePoint,
+}
+
+pub fn index_mode_from_env() -> StringIndexMode {
+ if std::env::var("NYASH_STR_CP").ok().as_deref() == Some("1") {
+ StringIndexMode::CodePoint
+ } else {
+ StringIndexMode::Byte
+ }
+}
+
+pub fn index_of(haystack: &str, needle: &str, start: Option, mode: StringIndexMode) -> i64 {
+ match mode {
+ StringIndexMode::Byte => index_of_bytes(haystack, needle, start),
+ StringIndexMode::CodePoint => index_of_codepoints(haystack, needle, start),
+ }
+}
+
+pub fn last_index_of(haystack: &str, needle: &str, mode: StringIndexMode) -> i64 {
+ match mode {
+ StringIndexMode::Byte => haystack.rfind(needle).map(|i| i as i64).unwrap_or(-1),
+ StringIndexMode::CodePoint => haystack
+ .rfind(needle)
+ .map(|byte_pos| haystack[..byte_pos].chars().count() as i64)
+ .unwrap_or(-1),
+ }
+}
+
+pub fn substring(haystack: &str, start: i64, end: Option, mode: StringIndexMode) -> String {
+ match mode {
+ StringIndexMode::Byte => substring_bytes(haystack, start, end),
+ StringIndexMode::CodePoint => substring_codepoints(haystack, start, end),
+ }
+}
+
+fn index_of_bytes(haystack: &str, needle: &str, start: Option) -> i64 {
+ let start_idx = start.unwrap_or(0).max(0) as usize;
+ if start_idx > haystack.len() {
+ return -1;
+ }
+ haystack[start_idx..]
+ .find(needle)
+ .map(|i| (start_idx + i) as i64)
+ .unwrap_or(-1)
+}
+
+fn index_of_codepoints(haystack: &str, needle: &str, start: Option) -> i64 {
+ let start_idx = start.unwrap_or(0).max(0) as usize;
+ let Some(byte_start) = byte_offset_for_cp(haystack, start_idx) else {
+ return -1;
+ };
+ if byte_start > haystack.len() {
+ return -1;
+ }
+ haystack[byte_start..]
+ .find(needle)
+ .map(|rel| {
+ let abs = byte_start + rel;
+ haystack[..abs].chars().count() as i64
+ })
+ .unwrap_or(-1)
+}
+
+fn substring_bytes(haystack: &str, start: i64, end: Option) -> String {
+ let len = haystack.len() as i64;
+ let start = start.max(0).min(len);
+ let end = end.unwrap_or(len).max(0).min(len);
+ if start > end {
+ return String::new();
+ }
+ let bytes = haystack.as_bytes();
+ String::from_utf8(bytes[start as usize..end as usize].to_vec()).unwrap_or_default()
+}
+
+fn substring_codepoints(haystack: &str, start: i64, end: Option) -> String {
+ let len = haystack.chars().count() as i64;
+ let start = start.max(0).min(len) as usize;
+ let end = end.unwrap_or(len).max(start as i64).min(len) as usize;
+ let chars: Vec = haystack.chars().collect();
+ chars[start..end].iter().collect()
+}
+
+fn byte_offset_for_cp(haystack: &str, cp_index: usize) -> Option {
+ let mut count = 0usize;
+ for (byte_pos, _) in haystack.char_indices() {
+ if count == cp_index {
+ return Some(byte_pos);
+ }
+ count += 1;
+ }
+ if count == cp_index {
+ Some(haystack.len())
+ } else {
+ None
+ }
+}
diff --git a/src/mir/builder/calls/guard.rs b/src/mir/builder/calls/guard.rs
index 64255dd1..3f88ddfb 100644
--- a/src/mir/builder/calls/guard.rs
+++ b/src/mir/builder/calls/guard.rs
@@ -14,6 +14,7 @@
use crate::mir::definitions::call_unified::CalleeBoxKind;
use crate::mir::{Callee, MirType, ValueId};
+use crate::runtime::core_method_aliases::canonical_method_name;
use std::collections::BTreeMap;
/// 構造ガード専用箱
@@ -103,15 +104,16 @@ impl<'a> CalleeGuardBox<'a> {
//
// Common string methods that should be routed to StringBox:
// length, substring, charAt, indexOf, etc.
+ let canonical = canonical_method_name(method);
let is_string_method = matches!(
- method.as_str(),
+ canonical,
"length"
| "substring"
| "charAt"
| "indexOf"
| "lastIndexOf"
- | "toUpperCase"
- | "toLowerCase"
+ | "toUpper"
+ | "toLower"
| "trim"
| "split"
);
diff --git a/src/mir/builder/control_flow/joinir/patterns/body_local_policy.rs b/src/mir/builder/control_flow/joinir/patterns/body_local_policy.rs
index 002ce3b1..6fb67823 100644
--- a/src/mir/builder/control_flow/joinir/patterns/body_local_policy.rs
+++ b/src/mir/builder/control_flow/joinir/patterns/body_local_policy.rs
@@ -6,7 +6,9 @@
use crate::ast::ASTNode;
use crate::mir::builder::MirBuilder;
use crate::mir::builder::control_flow::joinir::patterns::policies::PolicyDecision;
+use crate::mir::builder::control_flow::joinir::patterns::pattern2::contracts::derived_slot::extract_derived_slot_for_conditions;
use crate::mir::join_ir::lowering::carrier_info::CarrierInfo;
+use crate::mir::join_ir::lowering::common::body_local_derived_slot_emitter::BodyLocalDerivedSlotRecipe;
use crate::mir::join_ir::lowering::common::body_local_slot::{
ReadOnlyBodyLocalSlot, ReadOnlyBodyLocalSlotBox,
};
@@ -27,6 +29,7 @@ pub enum BodyLocalRoute {
carrier_name: String,
},
ReadOnlySlot(ReadOnlyBodyLocalSlot),
+ DerivedSlot(BodyLocalDerivedSlotRecipe),
}
pub fn classify_for_pattern2(
@@ -60,17 +63,29 @@ pub fn classify_for_pattern2(
carrier_info: promoted_carrier,
promoted_var,
carrier_name,
- } => PolicyDecision::Use(BodyLocalRoute::Promotion {
- promoted_carrier,
- promoted_var,
- carrier_name,
- }),
+ } => match extract_derived_slot_for_conditions(&vars, body) {
+ Ok(Some(recipe)) => PolicyDecision::Use(BodyLocalRoute::DerivedSlot(recipe)),
+ Ok(None) => PolicyDecision::Use(BodyLocalRoute::Promotion {
+ promoted_carrier,
+ promoted_var,
+ carrier_name,
+ }),
+ Err(slot_err) => PolicyDecision::Reject(format!(
+ "[pattern2/body_local_policy] derived-slot check failed: {slot_err}"
+ )),
+ },
ConditionPromotionResult::CannotPromote { reason, .. } => {
- match extract_body_local_inits_for_conditions(&vars, body) {
- Ok(Some(slot)) => PolicyDecision::Use(BodyLocalRoute::ReadOnlySlot(slot)),
- Ok(None) => PolicyDecision::Reject(reason),
+ match extract_derived_slot_for_conditions(&vars, body) {
+ Ok(Some(recipe)) => PolicyDecision::Use(BodyLocalRoute::DerivedSlot(recipe)),
+ Ok(None) => match extract_body_local_inits_for_conditions(&vars, body) {
+ Ok(Some(slot)) => PolicyDecision::Use(BodyLocalRoute::ReadOnlySlot(slot)),
+ Ok(None) => PolicyDecision::Reject(reason),
+ Err(slot_err) => PolicyDecision::Reject(format!(
+ "{reason}; read-only-slot rejected: {slot_err}"
+ )),
+ },
Err(slot_err) => PolicyDecision::Reject(format!(
- "{reason}; read-only-slot rejected: {slot_err}"
+ "{reason}; derived-slot rejected: {slot_err}"
)),
}
}
diff --git a/src/mir/builder/control_flow/joinir/patterns/loop_true_counter_extractor.rs b/src/mir/builder/control_flow/joinir/patterns/loop_true_counter_extractor.rs
index d00c77c3..b41c90c0 100644
--- a/src/mir/builder/control_flow/joinir/patterns/loop_true_counter_extractor.rs
+++ b/src/mir/builder/control_flow/joinir/patterns/loop_true_counter_extractor.rs
@@ -8,10 +8,11 @@
//! - 曖昧な loop(true) を **通さない**(Fail-Fast で理由を返す)
//!
//! ## Contract(Fail-Fast)
-//! 許可(read_digits(loop(true)) 系で必要な最小):
+//! 許可(loop(true) 系で必要な最小):
//! - カウンタ候補が **ちょうど1つ**
-//! - 更新が `i = i + 1` 形(定数 1 のみ)
-//! - `s.substring(i, i + 1)` 形が body のどこかに存在(誤マッチ防止)
+//! - 更新が `i = i + 1` 形(定数 1 のみ) **または**
+//! `i = j + K` 形(`j = s.indexOf(..., i)` 由来、K は整数定数)
+//! - `substring(i, ...)` が body のどこかに存在(誤マッチ防止)
//! - `i` が loop-outer var(`variable_map` に存在)である
//!
//! 禁止:
@@ -86,13 +87,6 @@ impl LoopTrueCounterExtractorBox {
}
}
- fn extract_var_name(n: &ASTNode) -> Option {
- match n {
- ASTNode::Variable { name, .. } => Some(name.clone()),
- _ => None,
- }
- }
-
fn is_self_plus_const_one(value: &ASTNode, target: &ASTNode) -> bool {
let target_name = match extract_var_name(target) {
Some(n) => n,
@@ -126,22 +120,122 @@ impl LoopTrueCounterExtractorBox {
candidates.sort();
candidates.dedup();
- let loop_var_name = match candidates.len() {
- 0 => {
- return Err(
- "[pattern2/loop_true_counter/contract/no_candidate] Cannot find unique counter update `i = i + 1` in loop(true) body"
- .to_string(),
- );
- }
- 1 => candidates[0].clone(),
- _ => {
+ if candidates.len() > 1 {
+ return Err(format!(
+ "[pattern2/loop_true_counter/contract/multiple_candidates] Multiple counter candidates found in loop(true) body: {:?}",
+ candidates
+ ));
+ }
+
+ if candidates.len() == 1 {
+ let loop_var_name = candidates[0].clone();
+ let host_id = variable_map.get(&loop_var_name).copied().ok_or_else(|| {
+ format!(
+ "[pattern2/loop_true_counter/contract/not_loop_outer] Counter '{}' not found in variable_map (loop-outer var required)",
+ loop_var_name
+ )
+ })?;
+
+ if !has_substring_read(body, &loop_var_name) {
return Err(format!(
- "[pattern2/loop_true_counter/contract/multiple_candidates] Multiple counter candidates found in loop(true) body: {:?}",
- candidates
+ "[pattern2/loop_true_counter/contract/missing_substring_guard] Counter '{}' found, but missing substring pattern `s.substring({}, {} + 1)`",
+ loop_var_name, loop_var_name, loop_var_name
));
}
- };
+ return Ok((loop_var_name, host_id));
+ }
+
+ if let Some((loop_var_name, host_id)) =
+ extract_loop_counter_from_indexof_pattern(body, variable_map)?
+ {
+ return Ok((loop_var_name, host_id));
+ }
+
+ Err(
+ "[pattern2/loop_true_counter/contract/no_candidate] Cannot find unique counter update `i = i + 1` in loop(true) body"
+ .to_string(),
+ )
+ }
+}
+
+fn extract_var_name(n: &ASTNode) -> Option {
+ match n {
+ ASTNode::Variable { name, .. } => Some(name.clone()),
+ _ => None,
+ }
+}
+
+fn extract_loop_counter_from_indexof_pattern(
+ body: &[ASTNode],
+ variable_map: &BTreeMap,
+) -> Result