From 7d8c0ab9e1279e1aeb48ad6968533b4b07efad95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 1 Aug 2026 13:51:28 +0200 Subject: shell: cat TIL sugar + precise ordering errors - `cat/hexdump TIL ` is now sugar for `USE TIL `: the paths are the input source, truncated at the first marker line across the combined stream. Path args + USE stays a syntax error (two input sources), now with a message that says so. - The parser's `trailing` flag conflated filters with the output keyword, so `cat f TO out TIL m` blamed the filters. USE/TIL now distinguish "before the filters" from "before the output keyword", and filters after the output keyword (`TO out -h 2`) are now rejected instead of silently accepted. - cat_file_until/hexdump_file_until return a tri-state (Done/Marker/ Aborted) so multi-path TIL truncation stops at the first marker. Verified in QEMU (10 checks): truncation, combined-stream marker, filters after the sugar, heredoc round trip, all four ordering-error messages, USE-with-paths ambiguity, fsck clean. --- shell/src/lib.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 108 insertions(+), 25 deletions(-) (limited to 'shell') diff --git a/shell/src/lib.rs b/shell/src/lib.rs index bc43faf..cf46cb0 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -52,7 +52,7 @@ pub fn run() -> ! { // Read one line from keyboard (tty canonical mode) match tty::read_line() { LineInput::Interrupted => continue, // ^C — fresh prompt - LineInput::Eof => continue, // ^D on an empty line — fresh prompt (no poweroff) + LineInput::Eof => continue, // ^D on an empty line — fresh prompt (no poweroff) LineInput::Line(line) => { let trimmed = line.trim(); if trimmed.is_empty() { @@ -132,7 +132,10 @@ fn tokenize(s: &str) -> Result, &'static str> { } fn is_keyword(s: &str) -> bool { - matches!(s, "WRITING" | "WRITE" | "TO" | "USE" | "USING" | "TIL" | "UNTIL") + matches!( + s, + "WRITING" | "WRITE" | "TO" | "USE" | "USING" | "TIL" | "UNTIL" + ) } /// How the command's input is fed. Only `cat`/`hexdump`, and only when @@ -189,7 +192,7 @@ fn parse(s: &str) -> Result { let mut i = 1; let mut args: Vec = Vec::new(); - + let mut filters: Vec = Vec::new(); let mut output: Option = None; let mut trailing = false; // a filter flag or output keyword has been seen @@ -204,6 +207,9 @@ fn parse(s: &str) -> Result { if !t.literal { match text { "USE" | "USING" => { + if output.is_some() { + return Err("USE must come before the output keyword".into()); + } if trailing { return Err("USE must come before the filters".into()); } @@ -220,6 +226,9 @@ fn parse(s: &str) -> Result { continue; } "TIL" | "UNTIL" => { + if output.is_some() { + return Err("TIL must come before the output keyword".into()); + } if trailing { return Err("TIL must come before the filters".into()); } @@ -255,6 +264,9 @@ fn parse(s: &str) -> Result { continue; } "-h" | "-t" => { + if output.is_some() { + return Err("filters must come before the output keyword".into()); + } trailing = true; i += 1; let n = toks.get(i).ok_or("flag needs a count")?; @@ -271,6 +283,9 @@ fn parse(s: &str) -> Result { continue; } "-g" => { + if output.is_some() { + return Err("filters must come before the output keyword".into()); + } trailing = true; i += 1; let pat = toks.get(i).ok_or("-g needs a pattern")?; @@ -279,6 +294,9 @@ fn parse(s: &str) -> Result { continue; } "-p" => { + if output.is_some() { + return Err("filters must come before the output keyword".into()); + } trailing = true; filters.push(Filter::Pause); i += 1; @@ -324,7 +342,10 @@ enum StageState { /// Rolling window of the last N lines. Tail(VecDeque), Grep, - Pause { shown: usize, screen: usize }, + Pause { + shown: usize, + screen: usize, + }, } /// Where the filtered line stream ends up. @@ -473,10 +494,9 @@ impl Pipeline { unreachable!("stage state does not match filter") }; for l in cur.drain(..) { - if *shown > 0 && *shown % *screen == 0 - && !pager_wait() { - return false; - } + if *shown > 0 && *shown % *screen == 0 && !pager_wait() { + return false; + } *shown += 1; out.push(l); } @@ -529,7 +549,10 @@ impl Pipeline { if let OutSink::File { h: None, .. } = &self.sink { self.open_sink(); } - if let OutSink::File { h: Some(h), pos, .. } = &mut self.sink { + if let OutSink::File { + h: Some(h), pos, .. + } = &mut self.sink + { let mut bytes = l.as_bytes().to_vec(); bytes.push(b'\n'); if let Ok(n) = rsnix::fs::write(*h, &bytes, *pos) { @@ -544,7 +567,9 @@ impl Pipeline { /// the file's current size. fn open_sink(&mut self) { let (path, overwrite) = match &self.sink { - OutSink::File { path, overwrite, .. } => (path.clone(), *overwrite), + OutSink::File { + path, overwrite, .. + } => (path.clone(), *overwrite), _ => return, }; match rsnix::fs::ensure_file(&path) { @@ -616,9 +641,16 @@ fn execute(s: &str) { term_err(&alloc::format!("{}: does not accept input", cmd)); return; } - if !parsed.args.is_empty() { + // `cat TIL ` is sugar for `cat USE TIL + // `: the paths are the input source, truncated at the + // marker. An explicit source keyword (USE / USE..TIL) alongside + // path arguments is genuinely two input sources. + if !parsed.args.is_empty() && !matches!(parsed.input, Input::Til(_)) { term_err(&alloc::format!( - "{}: input keyword with path arguments is ambiguous", + "{}: input source keyword with path arguments is ambiguous — \ +the paths are the input; drop the keyword, or to truncate them at a marker \ +write `{} TIL `", + cmd, cmd )); return; @@ -680,8 +712,10 @@ fn cmd_help(_p: &Parsed) { redirection keywords (uppercase, after args): USE/USING read command input from a file (cat/hexdump only) TIL/UNTIL read input until an exact marker line (heredoc) + cat TIL reads the path to the marker WRITING/WRITE write output to a file (overwrite) TO append output to a file + order: USE/TIL first, then the -flags, then WRITING/TO last a file sink replaces the screen; > >> < << are gone (> is plain text) post-filter flags (trailing, applied left to right): @@ -758,6 +792,14 @@ fn cmd_ls(p: &Parsed) { // cat — file and stream consumers // --------------------------------------------------------------------------- +/// How a file read ended: read to EOF, stopped at the TIL marker, or +/// aborted (^C / head kill) — the caller should stop processing paths. +enum ReadResult { + Done, + Marker, + Aborted, +} + fn cmd_cat(p: &Parsed) { let mut pipe = Pipeline::new(&p.filters, &p.output); match &p.input { @@ -778,7 +820,18 @@ fn cmd_cat(p: &Parsed) { cat_file(&mut pipe, path); } Input::Til(marker) => { - cat_stdin_until(&mut pipe, marker); + if p.args.is_empty() { + cat_stdin_until(&mut pipe, marker); + } else { + // `cat TIL ` — the paths are the input + // source, truncated at the first marker line. + for path in &p.args { + match cat_file_until(&mut pipe, path, marker) { + ReadResult::Done => {} + ReadResult::Marker | ReadResult::Aborted => break, + } + } + } } Input::UseTil(path, marker) => { cat_file_until(&mut pipe, path, marker); @@ -796,7 +849,7 @@ fn cat_stdin(pipe: &mut Pipeline) -> bool { } } LineInput::Interrupted => return false, // ^C aborts the stream - LineInput::Eof => return true, // ^D = EOF + LineInput::Eof => return true, // ^D = EOF } } } @@ -855,12 +908,12 @@ fn cat_file(pipe: &mut Pipeline, path: &str) -> bool { true } -fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> bool { +fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> ReadResult { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("cat: {}: no such file", path)); - return true; + return ReadResult::Done; } }; let mut buf = [0u8; 512]; @@ -869,7 +922,7 @@ fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> bool { let mut stopped = false; 'outer: loop { if tty::interrupt_pressed() { - return false; + return ReadResult::Aborted; } match rsnix::fs::read(h, &mut buf, pos) { Ok(0) | Err(_) => break, @@ -885,16 +938,20 @@ fn cat_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> bool { break 'outer; } if !pipe.line(line) { - return false; + return ReadResult::Aborted; } } } } } if !stopped && !pending.is_empty() && pending != marker && !pipe.line(&pending) { - return false; + return ReadResult::Aborted; + } + if stopped { + ReadResult::Marker + } else { + ReadResult::Done } - true } // --------------------------------------------------------------------------- @@ -998,7 +1055,18 @@ fn cmd_hexdump(p: &Parsed) { hexdump_file(&mut pipe, path); } Input::Til(marker) => { - hexdump_stdin_until(&mut pipe, marker); + if p.args.is_empty() { + hexdump_stdin_until(&mut pipe, marker); + } else { + // `hexdump TIL ` — the paths are the input + // source, truncated at the first marker line. + for path in &p.args { + match hexdump_file_until(&mut pipe, path, marker) { + ReadResult::Done => {} + ReadResult::Marker | ReadResult::Aborted => break, + } + } + } } Input::UseTil(path, marker) => { hexdump_file_until(&mut pipe, path, marker); @@ -1101,12 +1169,12 @@ fn hexdump_stdin_until(pipe: &mut Pipeline, marker: &str) { ctx.flush(); } -fn hexdump_file_until(pipe: &mut Pipeline, path: &str, marker: &str) { +fn hexdump_file_until(pipe: &mut Pipeline, path: &str, marker: &str) -> ReadResult { let h = match rsnix::fs::resolve(path) { Some(h) => h, None => { term_err(&alloc::format!("hexdump: {}: no such file", path)); - return; + return ReadResult::Done; } }; let mut ctx = HexCtx { @@ -1149,6 +1217,13 @@ fn hexdump_file_until(pipe: &mut Pipeline, path: &str, marker: &str) { ctx.feed(pending.as_bytes()); } ctx.flush(); + if !ctx.alive { + ReadResult::Aborted + } else if stopped { + ReadResult::Marker + } else { + ReadResult::Done + } } // --------------------------------------------------------------------------- @@ -1242,7 +1317,11 @@ fn cmd_mem(p: &Parsed) { let ps = rsnix::memory::page_stats(); let mut pipe = Pipeline::new(&p.filters, &p.output); - let _ = pipe.line(&alloc::format!("memory map (key=0x{:x}, {} entries)", key, map.len())); + let _ = pipe.line(&alloc::format!( + "memory map (key=0x{:x}, {} entries)", + key, + map.len() + )); let _ = pipe.line(&alloc::format!( " usable {:4} MB Conventional", conventional_kb / 1024 @@ -1304,7 +1383,11 @@ fn cmd_mem(p: &Parsed) { fn cmd_mmap(p: &Parsed) { let (map, key) = rsnix::memory::get_map(); let mut pipe = Pipeline::new(&p.filters, &p.output); - let _ = pipe.line(&alloc::format!("memory map (key=0x{:x}, {} entries):", key, map.len())); + let _ = pipe.line(&alloc::format!( + "memory map (key=0x{:x}, {} entries):", + key, + map.len() + )); let _ = pipe.line(" # type start pages size"); for (i, d) in map.iter().enumerate() { let l = alloc::format!( -- cgit v1.2.3