@@ -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