Skip to content

Commit 3e38289

Browse files
sisshiki1969claude
andauthored
io: IO.readlines / IO.foreach :encoding / :open_args / mode support (#565)
* io: IO.readlines / IO.foreach :encoding / :open_args / mode support Both class methods now resolve encodings via the shared class_read_opts (mode-string suffix, :encoding/:external_encoding/:internal_encoding, :open_args) and tag each line with the external encoding (transcoding external -> internal when set and the external is not BINARY) through a new tag_with_encs helper. A write/append-only :mode (without :open_args) raises IOError; the path is coerced via to_str/to_path; the limit arg is still coerced via #to_int (preserving the TypeError contract). ruby/spec (clean-master baseline -> now), zero regressions: core/io/readlines 34F/15E -> 29F/12E core/io/foreach 19F/13E -> 18F/10E read / gets / getc / print / puts / core/encoding / string-encode: unchanged https://claude.ai/code/session_01Xauk3yKxZT8aYG72ZpsU6U * io: align IO.readlines limit/sep coercion; cover more class-method paths IO.readlines now coerces a non-String separator via #to_str and a non-Integer limit via #to_int (raising TypeError only when the object truly can't convert), matching CRuby and IO.foreach -- it previously rejected any non-String/Integer/Hash arg with a blanket TypeError. Adds io_class_readlines_foreach_more_paths covering nil-separator (whole), #to_int limit objects, BINARY-external internal-drop, and the non-convertible-limit TypeError, all validated against CRuby 4.0.4. ruby/spec (clean-master baseline -> now), zero regressions: core/io/readlines 34F/15E -> 27F/7E core/io/foreach 19F/13E -> 18F/10E read / gets / getc / string each_line: unchanged https://claude.ai/code/session_01Xauk3yKxZT8aYG72ZpsU6U --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8d5938 commit 3e38289

1 file changed

Lines changed: 234 additions & 82 deletions

File tree

  • monoruby/src/builtins

monoruby/src/builtins/io.rs

Lines changed: 234 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,38 +1017,81 @@ fn io_class_readlines(
10171017
lfp: Lfp,
10181018
_: BytecodePtr,
10191019
) -> Result<Value> {
1020-
let path = lfp.arg(0).coerce_to_str(vm, globals)?;
1021-
// Validate any subsequent positional arguments. Hash arguments
1022-
// (forwarded keyword opts) are accepted; Integer limit is accepted but
1023-
// not enforced.
1020+
let path = lfp
1021+
.arg(0)
1022+
.coerce_to_path_rstring(vm, globals)?
1023+
.to_str()?
1024+
.to_string();
1025+
// arg1 may be a separator (String/nil), a limit (Integer) or an
1026+
// options Hash; arg2 may be the limit or the options Hash.
1027+
let mut opts = None;
1028+
let mut sep = "\n".to_string();
1029+
let mut whole = false;
10241030
for i in 1..3 {
1025-
if let Some(arg) = lfp.try_arg(i)
1026-
&& !arg.is_nil()
1027-
&& arg.try_hash_ty().is_none()
1028-
&& arg.try_fixnum().is_none()
1029-
&& arg.is_rstring().is_none()
1030-
{
1031-
return Err(MonorubyErr::typeerr(format!(
1032-
"no implicit conversion of {} into String",
1033-
globals.get_class_name(arg.class()),
1034-
)));
1031+
let Some(arg) = lfp.try_arg(i) else { continue };
1032+
if arg.is_nil() {
1033+
if i == 1 {
1034+
whole = true; // explicit nil separator -> read all
1035+
}
1036+
} else if let Some(h) = arg.try_hash_ty() {
1037+
opts = Some(h);
1038+
} else if arg.try_fixnum().is_some() {
1039+
// limit: accepted, not enforced
1040+
} else if let Some(s) = arg.is_str() {
1041+
if i == 1 {
1042+
sep = s.to_string();
1043+
}
1044+
} else if i == 1 {
1045+
sep = arg.coerce_to_str(vm, globals)?;
1046+
} else {
1047+
// limit position: coerce via #to_int (raises TypeError on a
1048+
// non-convertible object), matching CRuby. Not enforced.
1049+
let _ = arg.coerce_to_int_i64(vm, globals)?;
10351050
}
10361051
}
1037-
let content = std::fs::read_to_string(&path)
1052+
let (mode, ext_obj, int_obj) = class_read_opts(vm, globals, opts)?;
1053+
let base = mode.split(':').next().unwrap_or("").replace('b', "");
1054+
if base == "w" || base == "a" {
1055+
return Err(MonorubyErr::ioerr("not opened for reading"));
1056+
}
1057+
let content = std::fs::read(&path)
10381058
.map_err(|e| MonorubyErr::errno_with_path(&globals.store, &e, "rb_sysopen", &path))?;
1039-
let mut lines: Vec<Value> = Vec::new();
1040-
let mut start = 0;
1041-
let bytes = content.as_bytes();
1042-
while start < bytes.len() {
1043-
if let Some(pos) = content[start..].find('\n') {
1044-
let end = start + pos + 1;
1045-
lines.push(Value::string(content[start..end].to_string()));
1046-
start = end;
1047-
} else {
1048-
lines.push(Value::string(content[start..].to_string()));
1049-
break;
1059+
1060+
let mut chunks: Vec<Vec<u8>> = Vec::new();
1061+
if whole {
1062+
if !content.is_empty() {
1063+
chunks.push(content);
1064+
}
1065+
} else if sep.is_empty() {
1066+
// Paragraph mode: split on blank lines.
1067+
let text = String::from_utf8_lossy(&content);
1068+
for part in text.split("\n\n") {
1069+
let t = part.trim_start_matches('\n');
1070+
if !t.is_empty() {
1071+
chunks.push(format!("{t}\n").into_bytes());
1072+
}
1073+
}
1074+
} else {
1075+
let sb = sep.as_bytes();
1076+
let mut start = 0;
1077+
while start < content.len() {
1078+
if let Some(pos) = content[start..]
1079+
.windows(sb.len())
1080+
.position(|w| w == sb)
1081+
{
1082+
let end = start + pos + sb.len();
1083+
chunks.push(content[start..end].to_vec());
1084+
start = end;
1085+
} else {
1086+
chunks.push(content[start..].to_vec());
1087+
break;
1088+
}
10501089
}
10511090
}
1091+
let lines = chunks
1092+
.into_iter()
1093+
.map(|c| tag_with_encs(globals, c, ext_obj, int_obj))
1094+
.collect();
10521095
Ok(Value::array_from_vec(lines))
10531096
}
10541097

@@ -1074,72 +1117,72 @@ fn io_foreach(vm: &mut Executor, globals: &mut Globals, lfp: Lfp, _: BytecodePtr
10741117
}
10751118
};
10761119
let p = vm.get_block_data(globals, bh)?;
1077-
let content = std::fs::read_to_string(&path)
1078-
.map_err(|e| MonorubyErr::runtimeerr(format!("{}: {}", path, e)))?;
1079-
// arg1 may be a separator (String/nil), a limit (Integer), or an options
1080-
// Hash forwarded from `**opts` (CRuby allows
1081-
// `IO.foreach(path, chomp: true)` etc.). arg2, if present, is normally
1082-
// the limit, but may also be the options Hash. Both options and limit
1083-
// are currently accepted but not enforced — line slicing returns whole
1084-
// lines and the chomp/mode flags are ignored.
1085-
let sep = if let Some(sep_val) = lfp.try_arg(1) {
1086-
if sep_val.is_nil() {
1087-
None
1088-
} else if sep_val.try_fixnum().is_some() {
1089-
// arg1 is a limit, sep defaults to "\n".
1090-
Some("\n".to_string())
1091-
} else if sep_val.try_hash_ty().is_some() {
1092-
// arg1 is the options hash; sep defaults to "\n".
1093-
Some("\n".to_string())
1120+
// arg1: separator (String/nil), limit (Integer) or options Hash;
1121+
// arg2: limit or options Hash.
1122+
let mut opts = None;
1123+
let mut sep = Some("\n".to_string());
1124+
for i in 1..3 {
1125+
let Some(arg) = lfp.try_arg(i) else { continue };
1126+
if arg.is_nil() {
1127+
if i == 1 {
1128+
sep = None;
1129+
}
1130+
} else if let Some(h) = arg.try_hash_ty() {
1131+
opts = Some(h);
1132+
} else if arg.try_fixnum().is_some() {
1133+
// limit: accepted, not enforced
1134+
} else if i == 1 {
1135+
sep = Some(arg.coerce_to_str(vm, globals)?);
10941136
} else {
1095-
Some(sep_val.coerce_to_str(vm, globals)?)
1137+
// limit position: coerce via #to_int (raises TypeError if
1138+
// the object converts to neither Integer nor responds to
1139+
// #to_int), matching CRuby. Value itself is not enforced.
1140+
let _ = arg.coerce_to_int_i64(vm, globals)?;
10961141
}
1097-
} else {
1098-
Some("\n".to_string())
1099-
};
1100-
if let Some(arg2) = lfp.try_arg(2)
1101-
&& !arg2.is_nil()
1102-
&& arg2.try_hash_ty().is_none()
1103-
{
1104-
let _ = arg2.coerce_to_int_i64(vm, globals)?;
11051142
}
1106-
match sep {
1143+
let (mode, ext_obj, int_obj) = class_read_opts(vm, globals, opts)?;
1144+
let base = mode.split(':').next().unwrap_or("").replace('b', "");
1145+
if base == "w" || base == "a" {
1146+
return Err(MonorubyErr::ioerr("not opened for reading"));
1147+
}
1148+
let content = std::fs::read(&path)
1149+
.map_err(|e| MonorubyErr::runtimeerr(format!("{}: {}", path, e)))?;
1150+
1151+
let mut chunks: Vec<Vec<u8>> = Vec::new();
1152+
match &sep {
11071153
None => {
1108-
// When sep is nil, yield the entire content as one string
1109-
vm.invoke_block(globals, &p, &[Value::string(content)])?;
1154+
if !content.is_empty() {
1155+
chunks.push(content);
1156+
}
11101157
}
1111-
Some(sep) => {
1112-
let mut start = 0;
1113-
let content_bytes = content.as_bytes();
1114-
let sep_bytes = sep.as_bytes();
1115-
if sep_bytes.is_empty() {
1116-
// Paragraph mode: split on double newlines
1117-
let parts: Vec<&str> = content.split("\n\n").collect();
1118-
for part in parts {
1119-
let trimmed = part.trim_start_matches('\n');
1120-
if !trimmed.is_empty() {
1121-
let mut line = trimmed.to_string();
1122-
line.push('\n');
1123-
vm.invoke_block(globals, &p, &[Value::string(line)])?;
1124-
}
1158+
Some(s) if s.is_empty() => {
1159+
let text = String::from_utf8_lossy(&content);
1160+
for part in text.split("\n\n") {
1161+
let t = part.trim_start_matches('\n');
1162+
if !t.is_empty() {
1163+
chunks.push(format!("{t}\n").into_bytes());
11251164
}
1126-
} else {
1127-
while start < content_bytes.len() {
1128-
if let Some(pos) = content[start..].find(&sep) {
1129-
let end = start + pos + sep.len();
1130-
let line = &content[start..end];
1131-
vm.invoke_block(globals, &p, &[Value::string(line.to_string())])?;
1132-
start = end;
1133-
} else {
1134-
// Last line without separator
1135-
let line = &content[start..];
1136-
vm.invoke_block(globals, &p, &[Value::string(line.to_string())])?;
1137-
break;
1138-
}
1165+
}
1166+
}
1167+
Some(s) => {
1168+
let sb = s.as_bytes();
1169+
let mut start = 0;
1170+
while start < content.len() {
1171+
if let Some(pos) = content[start..].windows(sb.len()).position(|w| w == sb) {
1172+
let end = start + pos + sb.len();
1173+
chunks.push(content[start..end].to_vec());
1174+
start = end;
1175+
} else {
1176+
chunks.push(content[start..].to_vec());
1177+
break;
11391178
}
11401179
}
11411180
}
11421181
}
1182+
for c in chunks {
1183+
let line = tag_with_encs(globals, c, ext_obj, int_obj);
1184+
vm.invoke_block(globals, &p, &[line])?;
1185+
}
11431186
Ok(Value::nil())
11441187
}
11451188

@@ -2357,6 +2400,45 @@ fn enc_obj_to_enum(globals: &Globals, v: Value) -> Option<crate::value::Encoding
23572400
.and_then(|n| crate::value::Encoding::try_from_str(&n).ok())
23582401
}
23592402

2403+
/// Tag `bytes` with the resolved external encoding (defaulting to
2404+
/// `Encoding.default_external`), transcoding external -> internal when
2405+
/// an internal encoding is set and differs and the external is not
2406+
/// BINARY. Used by the `IO.readlines` / `IO.foreach` class methods.
2407+
fn tag_with_encs(
2408+
globals: &mut Globals,
2409+
bytes: Vec<u8>,
2410+
ext_obj: Option<Value>,
2411+
int_obj: Option<Value>,
2412+
) -> Value {
2413+
use crate::value::Encoding as E;
2414+
let ext = match ext_obj {
2415+
Some(o) => enc_obj_to_enum(globals, o).unwrap_or(E::Utf8),
2416+
None => {
2417+
let de = enc_default_external_obj(globals);
2418+
enc_obj_to_enum(globals, de).unwrap_or(E::Utf8)
2419+
}
2420+
};
2421+
let intl = int_obj.and_then(|o| enc_obj_to_enum(globals, o));
2422+
let (out, final_enc) = match intl {
2423+
Some(i) if i != ext && !matches!(ext, E::Ascii8) => {
2424+
let topts = super::encoding::TranscodeOpts {
2425+
invalid_replace: false,
2426+
undef_replace: false,
2427+
replace: None,
2428+
};
2429+
match super::encoding::transcode_bytes_with_opts(&bytes, ext, i, &topts, &globals.store)
2430+
{
2431+
Ok(b) => (b, i),
2432+
Err(_) => (bytes, ext),
2433+
}
2434+
}
2435+
_ => (bytes, ext),
2436+
};
2437+
let mut s = Value::string_from_vec(out);
2438+
s.as_rstring_inner_mut().set_encoding(final_enc);
2439+
s
2440+
}
2441+
23602442
/// Build the String returned by a read: tag it with the IO's external
23612443
/// encoding, transcoding external -> internal when an internal encoding
23622444
/// is set. `sized` reads (an explicit byte count) return ASCII-8BIT,
@@ -3881,6 +3963,76 @@ mod tests {
38813963
);
38823964
}
38833965

3966+
#[test]
3967+
fn io_class_readlines_foreach_encoding() {
3968+
run_test(
3969+
r#"
3970+
File.write("/tmp/mr_rlf.txt", "a\nb\nc\n")
3971+
r = []
3972+
r << IO.readlines("/tmp/mr_rlf.txt")
3973+
# (default-external name is locale-dependent in the sandbox
3974+
# oracle, so only explicit encodings are asserted)
3975+
r << IO.readlines("/tmp/mr_rlf.txt", encoding: "iso-8859-1")
3976+
.map { |l| l.encoding.name }.uniq
3977+
r << IO.readlines("/tmp/mr_rlf.txt", nil) # whole file, one elem
3978+
r << IO.readlines("/tmp/mr_rlf.txt", 2) # limit (ignored)
3979+
r << IO.readlines("/tmp/mr_rlf.txt",
3980+
open_args: ["r:euc-jp"]).first.encoding.name
3981+
acc = []
3982+
IO.foreach("/tmp/mr_rlf.txt", encoding: "iso-8859-1") do |line|
3983+
acc << [line, line.encoding.name]
3984+
end
3985+
r << acc
3986+
ext = []
3987+
IO.foreach("/tmp/mr_rlf.txt", external_encoding: "utf-8",
3988+
internal_encoding: "utf-16le") { |l| ext << l.encoding.name }
3989+
r << ext.uniq
3990+
File.unlink("/tmp/mr_rlf.txt")
3991+
r
3992+
"#,
3993+
);
3994+
// write/append-only mode (no :open_args) -> IOError.
3995+
run_test_error(
3996+
r#"File.write("/tmp/mr_rlf2.txt","x"); IO.readlines("/tmp/mr_rlf2.txt", mode: "w")"#,
3997+
);
3998+
run_test_error(
3999+
r#"File.write("/tmp/mr_rlf3.txt","x"); IO.foreach("/tmp/mr_rlf3.txt", mode: "a") { |l| l }"#,
4000+
);
4001+
// missing file -> Errno::ENOENT.
4002+
run_test_error(r#"IO.readlines("/tmp/mr_no_dir_qq/none.txt")"#);
4003+
}
4004+
4005+
#[test]
4006+
fn io_class_readlines_foreach_more_paths() {
4007+
run_test(
4008+
r#"
4009+
File.write("/tmp/mr_rlf4.txt", "x\ny\nz\n")
4010+
r = []
4011+
# foreach: whole content when separator is nil
4012+
acc = []
4013+
IO.foreach("/tmp/mr_rlf4.txt", nil) { |c| acc << c }
4014+
r << acc
4015+
# limit object: #to_int is invoked (value not enforced) and
4016+
# the lines are still yielded -> no error, full lines.
4017+
lim = Object.new
4018+
def lim.to_int; 3; end
4019+
cnt = []
4020+
IO.foreach("/tmp/mr_rlf4.txt", "\n", lim) { |l| cnt << l }
4021+
r << cnt
4022+
r << IO.readlines("/tmp/mr_rlf4.txt", "\n", lim)
4023+
# tag_with_encs: external BINARY => internal ignored.
4024+
s = IO.readlines("/tmp/mr_rlf4.txt", mode: "rb:binary:utf-8").first
4025+
r << s.encoding.name
4026+
File.unlink("/tmp/mr_rlf4.txt")
4027+
r
4028+
"#,
4029+
);
4030+
// A limit object that can't convert -> TypeError.
4031+
run_test_error(
4032+
r#"File.write("/tmp/mr_rlf5.txt","a\n"); IO.foreach("/tmp/mr_rlf5.txt", "\n", Object.new) { |l| l }"#,
4033+
);
4034+
}
4035+
38844036
#[test]
38854037
fn io_gets() {
38864038
// Read line-by-line via gets and stop at EOF (gets returns nil).

0 commit comments

Comments
 (0)