[uhttp] client
This commit is contained in:
parent
f548be3ea3
commit
9d6d8b672e
|
|
@ -47,10 +47,27 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
self.remaining_mut()[..s.len()].copy_from_slice(s);
|
self.remaining_mut()[..s.len()].copy_from_slice(s);
|
||||||
|
self.written_len += s.len();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.written_len = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pop_front_alloc(&mut self, amt: usize)
|
||||||
|
where
|
||||||
|
U: Copy + Default,
|
||||||
|
{
|
||||||
|
let to_copy = &self.filled()[amt..];
|
||||||
|
let buf_sz = to_copy.len();
|
||||||
|
let mut buf = vec![Default::default(); buf_sz].into_boxed_slice();
|
||||||
|
buf.copy_from_slice(to_copy);
|
||||||
|
self.clear();
|
||||||
|
let _ = self.extend_from_slice(&buf);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pop_front(&mut self, amt: usize)
|
pub fn pop_front(&mut self, amt: usize)
|
||||||
where
|
where
|
||||||
U: Copy,
|
U: Copy,
|
||||||
|
|
@ -69,7 +86,7 @@ where
|
||||||
// - U is copy
|
// - U is copy
|
||||||
// - count is within bounds of data
|
// - count is within bounds of data
|
||||||
unsafe { core::ptr::copy(src.as_ptr(), data.as_mut_ptr(), count) }
|
unsafe { core::ptr::copy(src.as_ptr(), data.as_mut_ptr(), count) }
|
||||||
|
|
||||||
self.written_len -= amt;
|
self.written_len -= amt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
crates/uhttp/examples/client.rs
Normal file
71
crates/uhttp/examples/client.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
//! A simple client that POSTs some data to a path
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example client -- <host> <port> <path> <data>
|
||||||
|
|
||||||
|
use std::{error::Error, io::Write, net::TcpStream};
|
||||||
|
|
||||||
|
use uhttp::Lift;
|
||||||
|
|
||||||
|
pub fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
let (host, port, path, data) = args
|
||||||
|
.next()
|
||||||
|
.and_then(|h| Some((h, args.next()?, args.next()?, args.next()?)))
|
||||||
|
.ok_or_else(|| Box::<dyn Error>::from(String::from("Missing required argument")))?;
|
||||||
|
|
||||||
|
let mut stream = TcpStream::connect((host, port.parse()?))?;
|
||||||
|
|
||||||
|
let buf = vec![0; 4096];
|
||||||
|
let mut buf = uhttp_ext::Buf::new(buf);
|
||||||
|
let mut conn = uhttp::Connection::new(uhttp::Role::Client);
|
||||||
|
let events = &[
|
||||||
|
uhttp::Event::RequestLine(uhttp::RequestLine {
|
||||||
|
version: uhttp::Version::HTTP1_1,
|
||||||
|
method: "POST",
|
||||||
|
target: &path,
|
||||||
|
}),
|
||||||
|
uhttp::Event::Header(uhttp::Header::Special(uhttp::HeaderSpecial::ContentLength(
|
||||||
|
data.len(),
|
||||||
|
))),
|
||||||
|
uhttp::Event::HeadersDone,
|
||||||
|
uhttp::Event::BodyChunk(data.as_bytes()),
|
||||||
|
uhttp::Event::SendDone,
|
||||||
|
];
|
||||||
|
|
||||||
|
for event in events {
|
||||||
|
if let Err(e) = conn.handle_send(event, &mut buf) {
|
||||||
|
panic!("{e:?}");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
stream.write_all(buf.filled())?;
|
||||||
|
|
||||||
|
buf.clear();
|
||||||
|
loop {
|
||||||
|
buf.read_from(&mut stream)?;
|
||||||
|
let data = buf.filled();
|
||||||
|
let (d, r) = conn.handle_recv(data).lift();
|
||||||
|
|
||||||
|
match r {
|
||||||
|
Err(uhttp::Error {
|
||||||
|
kind: uhttp::ErrorKind::NeedMoreData,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
continue;
|
||||||
|
},
|
||||||
|
Err(e) => panic!("{e:?}"),
|
||||||
|
Ok(ev) => {
|
||||||
|
println!("{ev}");
|
||||||
|
|
||||||
|
if ev == uhttp::Event::RecvDone {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let len = buf.filled().len() - d.len();
|
||||||
|
buf.pop_front(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
//! A simple echo server that listens on 127.0.0.1:8089
|
//! A simple echo server that echoes the body of a POST request, and returns a
|
||||||
|
//! 405 for any other method
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example echo -- <host> <port>
|
||||||
|
|
||||||
use std::{error::Error, io::Write, net::TcpListener};
|
use std::{error::Error, io::Write, net::TcpListener};
|
||||||
|
|
||||||
use uhttp::Lift;
|
use uhttp::Lift;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let listener = TcpListener::bind("127.0.0.1:8089")?;
|
let mut args = std::env::args().skip(1);
|
||||||
|
let (host, port) = args
|
||||||
|
.next()
|
||||||
|
.and_then(|h| Some((h, args.next()?)))
|
||||||
|
.ok_or_else(|| Box::<dyn Error>::from(String::from("Missing required argument")))?;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind((host, port.parse()?))?;
|
||||||
loop {
|
loop {
|
||||||
let (mut stream, _) = listener.accept()?;
|
let (mut stream, _) = listener.accept()?;
|
||||||
let mut conn = uhttp::Connection::new(uhttp::Role::Server);
|
let mut conn = uhttp::Connection::new(uhttp::Role::Server);
|
||||||
|
|
@ -50,7 +60,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
status_code: 405,
|
status_code: 405,
|
||||||
status_text: "Method not allowed",
|
status_text: "Method not allowed",
|
||||||
}),
|
}),
|
||||||
uhttp::Event::HeadersEnd,
|
uhttp::Event::HeadersDone,
|
||||||
uhttp::Event::SendDone,
|
uhttp::Event::SendDone,
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -63,7 +73,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
uhttp::Event::Header(uhttp::Header::Special(
|
uhttp::Event::Header(uhttp::Header::Special(
|
||||||
uhttp::HeaderSpecial::ContentLength(body.len()),
|
uhttp::HeaderSpecial::ContentLength(body.len()),
|
||||||
)),
|
)),
|
||||||
uhttp::Event::HeadersEnd,
|
uhttp::Event::HeadersDone,
|
||||||
uhttp::Event::BodyChunk(body.as_slice()),
|
uhttp::Event::BodyChunk(body.as_slice()),
|
||||||
uhttp::Event::SendDone,
|
uhttp::Event::SendDone,
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,44 @@ pub enum Event<'a> {
|
||||||
Empty,
|
Empty,
|
||||||
RequestLine(RequestLine<'a>),
|
RequestLine(RequestLine<'a>),
|
||||||
Header(Header<'a>),
|
Header(Header<'a>),
|
||||||
HeadersEnd,
|
HeadersDone,
|
||||||
BodyChunk(&'a [u8]),
|
BodyChunk(&'a [u8]),
|
||||||
RecvDone,
|
RecvDone,
|
||||||
StatusLine(StatusLine<'a>),
|
StatusLine(StatusLine<'a>),
|
||||||
SendDone,
|
SendDone,
|
||||||
}
|
}
|
||||||
|
impl<'a> core::fmt::Display for Event<'a> {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Event::Empty | Event::HeadersDone | Event::RecvDone | Event::SendDone => Ok(()),
|
||||||
|
Event::RequestLine(r) => write!(f, "{} {} {}", r.method, r.target, r.version),
|
||||||
|
Event::StatusLine(s) => {
|
||||||
|
write!(f, "{} {} {}", s.version, s.status_code, s.status_text)
|
||||||
|
},
|
||||||
|
Event::Header(h) => match h {
|
||||||
|
Header::Other(HeaderOther { name, value }) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}: {}",
|
||||||
|
name,
|
||||||
|
core::str::from_utf8(value).unwrap_or("<binary>")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Header::Special(h) => match h {
|
||||||
|
HeaderSpecial::ContentLength(cl) => write!(f, "Content-Length: {cl}"),
|
||||||
|
HeaderSpecial::TransferEncodingChunked => {
|
||||||
|
write!(f, "Transfer-Encoding: chunked")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Event::BodyChunk(b) => {
|
||||||
|
write!(f, "{}", core::str::from_utf8(b).unwrap_or("<binary>"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
pub enum Role {
|
pub enum Role {
|
||||||
Client,
|
Client,
|
||||||
Server,
|
Server,
|
||||||
|
|
@ -38,7 +68,7 @@ enum BodyState {
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
enum StateRecv {
|
enum StateRecv {
|
||||||
RequestLine,
|
StartLine,
|
||||||
Headers(Option<BodyState>),
|
Headers(Option<BodyState>),
|
||||||
Body(BodyState),
|
Body(BodyState),
|
||||||
ValidateDone,
|
ValidateDone,
|
||||||
|
|
@ -46,7 +76,7 @@ enum StateRecv {
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
enum StateSend {
|
enum StateSend {
|
||||||
StatusLine,
|
StartLine,
|
||||||
Headers(Option<BodyState>),
|
Headers(Option<BodyState>),
|
||||||
Body(BodyState),
|
Body(BodyState),
|
||||||
ValidateDone,
|
ValidateDone,
|
||||||
|
|
@ -58,7 +88,7 @@ enum StateConnection {
|
||||||
Send(StateSend),
|
Send(StateSend),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
pub struct Connection {
|
pub struct Connection {
|
||||||
role: Role,
|
role: Role,
|
||||||
state: StateConnection,
|
state: StateConnection,
|
||||||
|
|
@ -66,8 +96,8 @@ pub struct Connection {
|
||||||
impl Connection {
|
impl Connection {
|
||||||
pub fn new(role: Role) -> Self {
|
pub fn new(role: Role) -> Self {
|
||||||
let state = match role {
|
let state = match role {
|
||||||
Role::Server => StateConnection::Recv(StateRecv::RequestLine),
|
Role::Server => StateConnection::Recv(StateRecv::StartLine),
|
||||||
Role::Client => StateConnection::Send(StateSend::StatusLine),
|
Role::Client => StateConnection::Send(StateSend::StartLine),
|
||||||
};
|
};
|
||||||
|
|
||||||
Self { state, role }
|
Self { state, role }
|
||||||
|
|
@ -89,20 +119,27 @@ impl Connection {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let n = match recv {
|
let n = match (self.role, recv) {
|
||||||
StateRecv::RequestLine => parse::request_line(bytes).map2(|rl| {
|
(Role::Server, StateRecv::StartLine) => parse::request_line(bytes).map2(|rl| {
|
||||||
(
|
(
|
||||||
Event::RequestLine(rl),
|
Event::RequestLine(rl),
|
||||||
StateConnection::Recv(StateRecv::Headers(None)),
|
StateConnection::Recv(StateRecv::Headers(None)),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
StateRecv::Headers(body_state) => {
|
(Role::Client, StateRecv::StartLine) => parse::status_line(bytes).map2(|rl| {
|
||||||
|
(
|
||||||
|
Event::StatusLine(rl),
|
||||||
|
StateConnection::Recv(StateRecv::Headers(None)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
|
||||||
|
(_, StateRecv::Headers(body_state)) => {
|
||||||
if bytes.starts_with(b"\r\n") {
|
if bytes.starts_with(b"\r\n") {
|
||||||
Ok((
|
Ok((
|
||||||
&bytes[2..],
|
&bytes[2..],
|
||||||
(
|
(
|
||||||
Event::HeadersEnd,
|
Event::HeadersDone,
|
||||||
match body_state {
|
match body_state {
|
||||||
None => StateConnection::Recv(StateRecv::ValidateDone),
|
None => StateConnection::Recv(StateRecv::ValidateDone),
|
||||||
Some(b) => StateConnection::Recv(StateRecv::Body(*b)),
|
Some(b) => StateConnection::Recv(StateRecv::Body(*b)),
|
||||||
|
|
@ -129,7 +166,7 @@ impl Connection {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
StateRecv::Body(body_state) => match body_state {
|
(_, StateRecv::Body(body_state)) => match body_state {
|
||||||
BodyState::ContentLength(remaining) => {
|
BodyState::ContentLength(remaining) => {
|
||||||
if bytes.len() < *remaining {
|
if bytes.len() < *remaining {
|
||||||
Ok((
|
Ok((
|
||||||
|
|
@ -154,14 +191,11 @@ impl Connection {
|
||||||
_ => todo!(),
|
_ => todo!(),
|
||||||
},
|
},
|
||||||
|
|
||||||
StateRecv::ValidateDone => {
|
(_, StateRecv::ValidateDone) => {
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
Ok((
|
Ok((
|
||||||
bytes,
|
bytes,
|
||||||
(
|
(Event::RecvDone, StateConnection::Send(StateSend::StartLine)),
|
||||||
Event::RecvDone,
|
|
||||||
StateConnection::Send(StateSend::StatusLine),
|
|
||||||
),
|
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
fail(ErrorKind::TrailingBytes).tup(bytes)
|
fail(ErrorKind::TrailingBytes).tup(bytes)
|
||||||
|
|
@ -182,11 +216,18 @@ impl Connection {
|
||||||
_ => return fail(ErrorKind::InvalidConnectionState),
|
_ => return fail(ErrorKind::InvalidConnectionState),
|
||||||
};
|
};
|
||||||
|
|
||||||
let next = match (state, event) {
|
let next = match (self.role, state, event) {
|
||||||
(StateSend::StatusLine, Event::StatusLine(sl)) => write::status_line(sl, w)
|
(Role::Server, StateSend::StartLine, Event::StatusLine(sl)) => {
|
||||||
.map(|_| StateConnection::Send(StateSend::Headers(None))),
|
write::status_line(sl, w)
|
||||||
|
.map(|_| StateConnection::Send(StateSend::Headers(None)))
|
||||||
|
},
|
||||||
|
|
||||||
(StateSend::Headers(body_state), Event::Header(h)) => {
|
(Role::Client, StateSend::StartLine, Event::RequestLine(rl)) => {
|
||||||
|
write::request_line(rl, w)
|
||||||
|
.map(|_| StateConnection::Send(StateSend::Headers(None)))
|
||||||
|
},
|
||||||
|
|
||||||
|
(_, StateSend::Headers(body_state), Event::Header(h)) => {
|
||||||
write::header(h, w)?;
|
write::header(h, w)?;
|
||||||
|
|
||||||
let bs = match h {
|
let bs = match h {
|
||||||
|
|
@ -202,7 +243,7 @@ impl Connection {
|
||||||
Ok(StateConnection::Send(StateSend::Headers(bs)))
|
Ok(StateConnection::Send(StateSend::Headers(bs)))
|
||||||
},
|
},
|
||||||
|
|
||||||
(StateSend::Headers(body_state), Event::HeadersEnd) => {
|
(_, StateSend::Headers(body_state), Event::HeadersDone) => {
|
||||||
write!(w, "\r\n")?;
|
write!(w, "\r\n")?;
|
||||||
match body_state {
|
match body_state {
|
||||||
Some(bs) => Ok(StateConnection::Send(StateSend::Body(bs))),
|
Some(bs) => Ok(StateConnection::Send(StateSend::Body(bs))),
|
||||||
|
|
@ -210,7 +251,7 @@ impl Connection {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
(StateSend::Body(b), Event::BodyChunk(c)) => match b {
|
(_, StateSend::Body(b), Event::BodyChunk(c)) => match b {
|
||||||
BodyState::ContentLength(cl) => match () {
|
BodyState::ContentLength(cl) => match () {
|
||||||
_ if c.len() < cl => {
|
_ if c.len() < cl => {
|
||||||
w.write(c)?;
|
w.write(c)?;
|
||||||
|
|
@ -233,8 +274,8 @@ impl Connection {
|
||||||
BodyState::Chunked => todo!(),
|
BodyState::Chunked => todo!(),
|
||||||
},
|
},
|
||||||
|
|
||||||
(StateSend::ValidateDone, Event::SendDone) => {
|
(_, StateSend::ValidateDone, Event::SendDone) => {
|
||||||
Ok(StateConnection::Recv(StateRecv::RequestLine))
|
Ok(StateConnection::Recv(StateRecv::StartLine))
|
||||||
},
|
},
|
||||||
|
|
||||||
_ => return Err(Error::from(ErrorKind::InvalidEventForConnectionState)),
|
_ => return Err(Error::from(ErrorKind::InvalidEventForConnectionState)),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
fail_details,
|
fail_details,
|
||||||
parts::{RequestLine, Version},
|
parts::{RequestLine, Version},
|
||||||
Error, ErrorKind, Header, HeaderOther, HeaderSpecial, Tup,
|
Error, ErrorKind, Header, HeaderOther, HeaderSpecial, StatusLine, Tup,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub type Parse<'a, T> = Result<(&'a [u8], T), (&'a [u8], Error)>;
|
pub type Parse<'a, T> = Result<(&'a [u8], T), (&'a [u8], Error)>;
|
||||||
|
|
@ -61,6 +61,50 @@ pub fn request_line(d: &[u8]) -> Parse<RequestLine> {
|
||||||
go().tup(rest)
|
go().tup(rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn status_line(d: &[u8]) -> Parse<StatusLine> {
|
||||||
|
let (line, rest) = split_crlf(d).ok_or((d, ErrorKind::NeedMoreData.into()))?;
|
||||||
|
|
||||||
|
let go = || {
|
||||||
|
let mut it = line
|
||||||
|
.split(|b| b.is_ascii_whitespace())
|
||||||
|
.filter(|bs| !bs.is_empty());
|
||||||
|
|
||||||
|
let (version, status_code, status_text) = match (it.next(), it.next(), it.next()) {
|
||||||
|
(Some(m), Some(t), Some(v)) => (m, t, v),
|
||||||
|
_ => {
|
||||||
|
return fail_details(
|
||||||
|
ErrorKind::Parse,
|
||||||
|
"status line doesn't have required number of elements",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let version = match () {
|
||||||
|
_ if version.eq_ignore_ascii_case(b"http/1.1") => Version::HTTP1_1,
|
||||||
|
_ => {
|
||||||
|
return fail_details(ErrorKind::Parse, "unknown http version");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_code = core::str::from_utf8(status_code)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or_else(|| Error::with_details(ErrorKind::Parse, "invalid status code"))?;
|
||||||
|
|
||||||
|
let status_text = core::str::from_utf8(status_text)
|
||||||
|
.ok()
|
||||||
|
.ok_or_else(|| Error::with_details(ErrorKind::Parse, "invalid status text"))?;
|
||||||
|
|
||||||
|
Ok(StatusLine {
|
||||||
|
version,
|
||||||
|
status_code,
|
||||||
|
status_text,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
go().tup(rest)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn header(d: &[u8]) -> Parse<Header> {
|
pub fn header(d: &[u8]) -> Parse<Header> {
|
||||||
let (line, rest) = split_crlf(d).ok_or((d, ErrorKind::NeedMoreData.into()))?;
|
let (line, rest) = split_crlf(d).ok_or((d, ErrorKind::NeedMoreData.into()))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
use crate::{ErrorKind, Write};
|
|
||||||
|
|
||||||
pub trait ResultTupExt<A, T, B, E> {
|
pub trait ResultTupExt<A, T, B, E> {
|
||||||
fn map2<U>(self, f: impl FnOnce(T) -> U) -> Result<(A, U), (B, E)>;
|
fn map2<U>(self, f: impl FnOnce(T) -> U) -> Result<(A, U), (B, E)>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use core::fmt::Arguments;
|
use core::fmt::Arguments;
|
||||||
|
|
||||||
use crate::{Error, ErrorKind, Header, HeaderOther, HeaderSpecial, StatusLine};
|
use crate::{Error, ErrorKind, Header, HeaderOther, HeaderSpecial, RequestLine, StatusLine};
|
||||||
|
|
||||||
pub struct FmtWriteAdapter<T> {
|
pub struct FmtWriteAdapter<T> {
|
||||||
inner: T,
|
inner: T,
|
||||||
|
|
@ -102,6 +102,10 @@ pub fn status_line(sl: &StatusLine, mut w: impl Write) -> Written {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn request_line(rl: &RequestLine, mut w: impl Write) -> Written {
|
||||||
|
write!(w, "{} {} {}\r\n", rl.method, rl.target, rl.version)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn header(h: &Header, mut w: impl Write) -> Written {
|
pub fn header(h: &Header, mut w: impl Write) -> Written {
|
||||||
match h {
|
match h {
|
||||||
Header::Special(h) => match h {
|
Header::Special(h) => match h {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue