mirror of https://github.com/chipsenkbeil/distant
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1169 lines
36 KiB
Rust
1169 lines
36 KiB
Rust
3 years ago
|
use crate::client::{SessionInfo, SessionInfoParseError};
|
||
3 years ago
|
use derive_more::{Display, Error, From};
|
||
|
use serde::{Deserialize, Serialize};
|
||
|
use serde_json::{Map, Value};
|
||
|
use std::{
|
||
|
fmt,
|
||
|
io::{self, BufRead},
|
||
|
ops::{Deref, DerefMut},
|
||
|
str::FromStr,
|
||
|
string::FromUtf8Error,
|
||
|
};
|
||
|
|
||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Display, Error, From)]
|
||
3 years ago
|
pub enum LspSessionInfoError {
|
||
3 years ago
|
/// Encountered when attempting to create a session from a request that is not initialize
|
||
|
NotInitializeRequest,
|
||
|
|
||
|
/// Encountered if missing session parameters within an initialize request
|
||
3 years ago
|
MissingSessionInfoParams,
|
||
3 years ago
|
|
||
|
/// Encountered if session parameters are not expected types
|
||
3 years ago
|
InvalidSessionInfoParams,
|
||
3 years ago
|
|
||
|
/// Encountered when failing to parse session
|
||
3 years ago
|
SessionInfoParseError(SessionInfoParseError),
|
||
3 years ago
|
}
|
||
|
|
||
3 years ago
|
impl From<LspSessionInfoError> for io::Error {
|
||
|
fn from(x: LspSessionInfoError) -> Self {
|
||
3 years ago
|
match x {
|
||
3 years ago
|
LspSessionInfoError::SessionInfoParseError(x) => x.into(),
|
||
3 years ago
|
x => io::Error::new(io::ErrorKind::InvalidData, x),
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// Represents some data being communicated to/from an LSP consisting of a header and content part
|
||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
|
pub struct LspData {
|
||
|
/// Header-portion of some data related to LSP
|
||
3 years ago
|
header: LspHeader,
|
||
3 years ago
|
|
||
|
/// Content-portion of some data related to LSP
|
||
3 years ago
|
content: LspContent,
|
||
3 years ago
|
}
|
||
|
|
||
|
#[derive(Debug, Display, Error, From)]
|
||
|
pub enum LspDataParseError {
|
||
|
/// When the received content is malformed
|
||
3 years ago
|
BadContent(LspContentParseError),
|
||
3 years ago
|
|
||
|
/// When the received header is malformed
|
||
3 years ago
|
BadHeader(LspHeaderParseError),
|
||
3 years ago
|
|
||
|
/// When a header line is not terminated in \r\n
|
||
|
BadHeaderTermination,
|
||
|
|
||
|
/// When input fails to be in UTF-8 format
|
||
|
BadInput(FromUtf8Error),
|
||
|
|
||
|
/// When some unexpected I/O error encountered
|
||
|
IoError(io::Error),
|
||
|
|
||
|
/// When EOF received before data fully acquired
|
||
|
UnexpectedEof,
|
||
|
}
|
||
|
|
||
|
impl From<LspDataParseError> for io::Error {
|
||
|
fn from(x: LspDataParseError) -> Self {
|
||
|
match x {
|
||
|
LspDataParseError::BadContent(x) => x.into(),
|
||
|
LspDataParseError::BadHeader(x) => x.into(),
|
||
|
LspDataParseError::BadHeaderTermination => io::Error::new(
|
||
|
io::ErrorKind::InvalidData,
|
||
|
r"Received header line not terminated in \r\n",
|
||
|
),
|
||
|
LspDataParseError::BadInput(x) => io::Error::new(io::ErrorKind::InvalidData, x),
|
||
|
LspDataParseError::IoError(x) => x,
|
||
|
LspDataParseError::UnexpectedEof => io::Error::from(io::ErrorKind::UnexpectedEof),
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl LspData {
|
||
|
/// Returns a reference to the header part
|
||
3 years ago
|
pub fn header(&self) -> &LspHeader {
|
||
3 years ago
|
&self.header
|
||
|
}
|
||
3 years ago
|
/// Returns a mutable reference to the header part
|
||
|
pub fn mut_header(&mut self) -> &mut LspHeader {
|
||
|
&mut self.header
|
||
|
}
|
||
3 years ago
|
|
||
|
/// Returns a reference to the content part
|
||
3 years ago
|
pub fn content(&self) -> &LspContent {
|
||
3 years ago
|
&self.content
|
||
|
}
|
||
|
|
||
3 years ago
|
/// Returns a mutable reference to the content part
|
||
|
pub fn mut_content(&mut self) -> &mut LspContent {
|
||
|
&mut self.content
|
||
|
}
|
||
|
|
||
3 years ago
|
/// Updates the header content length based on the current content
|
||
|
pub fn refresh_content_length(&mut self) {
|
||
|
self.header.content_length = self.content.to_string().len();
|
||
|
}
|
||
|
|
||
3 years ago
|
/// Creates a session's info by inspecting the content for session parameters, removing the
|
||
|
/// session parameters from the content. Will also adjust the content length header to match
|
||
|
/// the new size of the content.
|
||
|
pub fn take_session_info(&mut self) -> Result<SessionInfo, LspSessionInfoError> {
|
||
|
match self.content.take_session_info() {
|
||
3 years ago
|
Ok(session) => {
|
||
3 years ago
|
self.refresh_content_length();
|
||
3 years ago
|
Ok(session)
|
||
|
}
|
||
|
Err(x) => Err(x),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// Attempts to read incoming lsp data from a buffered reader.
|
||
|
///
|
||
3 years ago
|
/// Note that this is **blocking** while it waits on the header information (or EOF)!
|
||
3 years ago
|
///
|
||
|
/// ```text
|
||
|
/// Content-Length: ...\r\n
|
||
|
/// Content-Type: ...\r\n
|
||
|
/// \r\n
|
||
|
/// {
|
||
|
/// "jsonrpc": "2.0",
|
||
|
/// ...
|
||
|
/// }
|
||
|
/// ```
|
||
|
pub fn from_buf_reader<R: BufRead>(r: &mut R) -> Result<Self, LspDataParseError> {
|
||
|
// Read in our headers first so we can figure out how much more to read
|
||
|
let mut buf = String::new();
|
||
|
loop {
|
||
|
// Track starting position for new buffer content
|
||
|
let start = buf.len();
|
||
|
|
||
|
// Block on each line of input!
|
||
|
let len = r.read_line(&mut buf)?;
|
||
|
let end = start + len;
|
||
|
|
||
|
// We shouldn't be getting end of the reader yet
|
||
|
if len == 0 {
|
||
|
return Err(LspDataParseError::UnexpectedEof);
|
||
|
}
|
||
|
|
||
|
let line = &buf[start..end];
|
||
|
|
||
|
// Check if we've gotten bad data
|
||
|
if !line.ends_with("\r\n") {
|
||
|
return Err(LspDataParseError::BadHeaderTermination);
|
||
|
|
||
|
// Check if we've received the header termination
|
||
|
} else if line == "\r\n" {
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Parse the header content so we know how much more to read
|
||
3 years ago
|
let header = buf.parse::<LspHeader>()?;
|
||
3 years ago
|
|
||
|
// Read remaining content
|
||
|
let content = {
|
||
|
let mut buf = vec![0u8; header.content_length];
|
||
|
r.read_exact(&mut buf).map_err(|x| {
|
||
|
if x.kind() == io::ErrorKind::UnexpectedEof {
|
||
|
LspDataParseError::UnexpectedEof
|
||
|
} else {
|
||
|
LspDataParseError::IoError(x)
|
||
|
}
|
||
|
})?;
|
||
3 years ago
|
String::from_utf8(buf)?.parse::<LspContent>()?
|
||
3 years ago
|
};
|
||
|
|
||
|
Ok(Self { header, content })
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl fmt::Display for LspData {
|
||
|
/// Outputs header & content in form
|
||
|
///
|
||
|
/// ```text
|
||
|
/// Content-Length: ...\r\n
|
||
|
/// Content-Type: ...\r\n
|
||
|
/// \r\n
|
||
|
/// {
|
||
|
/// "jsonrpc": "2.0",
|
||
|
/// ...
|
||
|
/// }
|
||
|
/// ```
|
||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
self.header.fmt(f)?;
|
||
|
self.content.fmt(f)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl FromStr for LspData {
|
||
|
type Err = LspDataParseError;
|
||
|
|
||
|
/// Parses headers and content in the form of
|
||
|
///
|
||
|
/// ```text
|
||
|
/// Content-Length: ...\r\n
|
||
|
/// Content-Type: ...\r\n
|
||
|
/// \r\n
|
||
|
/// {
|
||
|
/// "jsonrpc": "2.0",
|
||
|
/// ...
|
||
|
/// }
|
||
|
/// ```
|
||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
|
let mut r = io::BufReader::new(io::Cursor::new(s));
|
||
|
Self::from_buf_reader(&mut r)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// Represents the header for LSP data
|
||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
3 years ago
|
pub struct LspHeader {
|
||
3 years ago
|
/// Length of content part in bytes
|
||
|
pub content_length: usize,
|
||
|
|
||
|
/// Mime type of content part, defaulting to
|
||
|
/// application/vscode-jsonrpc; charset=utf-8
|
||
|
pub content_type: Option<String>,
|
||
|
}
|
||
|
|
||
3 years ago
|
impl fmt::Display for LspHeader {
|
||
3 years ago
|
/// Outputs header in form
|
||
|
///
|
||
|
/// ```text
|
||
|
/// Content-Length: ...\r\n
|
||
|
/// Content-Type: ...\r\n
|
||
|
/// \r\n
|
||
|
/// ```
|
||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
write!(f, "Content-Length: {}\r\n", self.content_length)?;
|
||
|
|
||
|
if let Some(ty) = self.content_type.as_ref() {
|
||
|
write!(f, "Content-Type: {}\r\n", ty)?;
|
||
|
}
|
||
|
|
||
|
write!(f, "\r\n")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Clone, Debug, PartialEq, Eq, Display, Error, From)]
|
||
3 years ago
|
pub enum LspHeaderParseError {
|
||
3 years ago
|
MissingContentLength,
|
||
|
InvalidContentLength(std::num::ParseIntError),
|
||
|
BadHeaderField,
|
||
|
}
|
||
|
|
||
3 years ago
|
impl From<LspHeaderParseError> for io::Error {
|
||
|
fn from(x: LspHeaderParseError) -> Self {
|
||
3 years ago
|
io::Error::new(io::ErrorKind::InvalidData, x)
|
||
|
}
|
||
|
}
|
||
|
|
||
3 years ago
|
impl FromStr for LspHeader {
|
||
|
type Err = LspHeaderParseError;
|
||
3 years ago
|
|
||
|
/// Parses headers in the form of
|
||
|
///
|
||
|
/// ```text
|
||
|
/// Content-Length: ...\r\n
|
||
|
/// Content-Type: ...\r\n
|
||
|
/// \r\n
|
||
|
/// ```
|
||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
|
let lines = s.split("\r\n").map(str::trim).filter(|l| !l.is_empty());
|
||
|
let mut content_length = None;
|
||
|
let mut content_type = None;
|
||
|
|
||
|
for line in lines {
|
||
3 years ago
|
match line.find(':') {
|
||
|
Some(idx) if idx + 1 < line.len() => {
|
||
|
let name = &line[..idx];
|
||
|
let value = &line[(idx + 1)..];
|
||
|
match name {
|
||
|
"Content-Length" => content_length = Some(value.trim().parse()?),
|
||
|
"Content-Type" => content_type = Some(value.trim().to_string()),
|
||
|
_ => return Err(LspHeaderParseError::BadHeaderField),
|
||
|
}
|
||
|
}
|
||
|
_ => {
|
||
|
return Err(LspHeaderParseError::BadHeaderField);
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
match content_length {
|
||
|
Some(content_length) => Ok(Self {
|
||
|
content_length,
|
||
|
content_type,
|
||
|
}),
|
||
3 years ago
|
None => Err(LspHeaderParseError::MissingContentLength),
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// Represents the content for LSP data
|
||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
3 years ago
|
pub struct LspContent(Map<String, Value>);
|
||
|
|
||
3 years ago
|
fn for_each_mut_string<F1, F2>(value: &mut Value, check: &F1, mutate: &mut F2)
|
||
3 years ago
|
where
|
||
|
F1: Fn(&String) -> bool,
|
||
|
F2: FnMut(&mut String),
|
||
|
{
|
||
|
match value {
|
||
|
Value::Object(obj) => {
|
||
|
// Mutate values
|
||
|
obj.values_mut()
|
||
|
.for_each(|v| for_each_mut_string(v, check, mutate));
|
||
|
|
||
|
// Mutate keys if necessary
|
||
3 years ago
|
let keys: Vec<String> = obj
|
||
|
.keys()
|
||
|
.filter(|k| check(k))
|
||
|
.map(ToString::to_string)
|
||
|
.collect();
|
||
|
for key in keys {
|
||
|
if let Some((mut key, value)) = obj.remove_entry(&key) {
|
||
|
mutate(&mut key);
|
||
|
obj.insert(key, value);
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
Value::Array(items) => items
|
||
|
.iter_mut()
|
||
|
.for_each(|v| for_each_mut_string(v, check, mutate)),
|
||
|
Value::String(s) => mutate(s),
|
||
|
_ => {}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fn swap_prefix(obj: &mut Map<String, Value>, old: &str, new: &str) {
|
||
|
let check = |s: &String| s.starts_with(old);
|
||
3 years ago
|
let mut mutate = |s: &mut String| {
|
||
3 years ago
|
if let Some(pos) = s.find(old) {
|
||
|
s.replace_range(pos..old.len(), new);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Mutate values
|
||
|
obj.values_mut()
|
||
3 years ago
|
.for_each(|v| for_each_mut_string(v, &check, &mut mutate));
|
||
3 years ago
|
|
||
|
// Mutate keys if necessary
|
||
3 years ago
|
let keys: Vec<String> = obj
|
||
|
.keys()
|
||
|
.filter(|k| check(k))
|
||
|
.map(ToString::to_string)
|
||
|
.collect();
|
||
|
for key in keys {
|
||
|
if let Some((mut key, value)) = obj.remove_entry(&key) {
|
||
|
mutate(&mut key);
|
||
|
obj.insert(key, value);
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl LspContent {
|
||
|
/// Converts all URIs with `file://` as the scheme to `distant://` instead
|
||
|
pub fn convert_local_scheme_to_distant(&mut self) {
|
||
|
swap_prefix(&mut self.0, "file:", "distant:");
|
||
|
}
|
||
3 years ago
|
|
||
3 years ago
|
/// Converts all URIs with `distant://` as the scheme to `file://` instead
|
||
|
pub fn convert_distant_scheme_to_local(&mut self) {
|
||
|
swap_prefix(&mut self.0, "distant:", "file:");
|
||
|
}
|
||
|
|
||
|
/// Creates a session's info by inspecting the content for session parameters, removing the
|
||
|
/// session parameters from the content
|
||
|
pub fn take_session_info(&mut self) -> Result<SessionInfo, LspSessionInfoError> {
|
||
3 years ago
|
// Verify that we're dealing with an initialize request
|
||
|
match self.0.get("method") {
|
||
|
Some(value) if value == "initialize" => {}
|
||
3 years ago
|
_ => return Err(LspSessionInfoError::NotInitializeRequest),
|
||
3 years ago
|
}
|
||
|
|
||
|
// Attempt to grab the distant initialization options
|
||
|
match self.strip_session_params() {
|
||
3 years ago
|
Some((Some(host), Some(port), Some(key))) => {
|
||
3 years ago
|
let host = host
|
||
|
.as_str()
|
||
|
.ok_or(LspSessionInfoError::InvalidSessionInfoParams)?;
|
||
|
let port = port
|
||
|
.as_u64()
|
||
|
.ok_or(LspSessionInfoError::InvalidSessionInfoParams)?;
|
||
3 years ago
|
let key = key
|
||
3 years ago
|
.as_str()
|
||
3 years ago
|
.ok_or(LspSessionInfoError::InvalidSessionInfoParams)?;
|
||
3 years ago
|
Ok(format!("DISTANT DATA {} {} {}", host, port, key).parse()?)
|
||
3 years ago
|
}
|
||
3 years ago
|
_ => Err(LspSessionInfoError::MissingSessionInfoParams),
|
||
3 years ago
|
}
|
||
|
}
|
||
|
|
||
|
/// Strips the session params from the content, returning them if they exist
|
||
|
///
|
||
|
/// ```json
|
||
|
/// {
|
||
|
/// "params": {
|
||
|
/// "initializationOptions": {
|
||
|
/// "distant": {
|
||
|
/// "host": "...",
|
||
|
/// "port": ...,
|
||
3 years ago
|
/// "key": "..."
|
||
3 years ago
|
/// }
|
||
|
/// }
|
||
|
/// }
|
||
|
/// }
|
||
|
/// ```
|
||
|
fn strip_session_params(&mut self) -> Option<(Option<Value>, Option<Value>, Option<Value>)> {
|
||
|
self.0
|
||
|
.get_mut("params")
|
||
|
.and_then(|v| v.get_mut("initializationOptions"))
|
||
|
.and_then(|v| v.as_object_mut())
|
||
|
.and_then(|o| o.remove("distant"))
|
||
|
.map(|mut v| {
|
||
|
(
|
||
|
v.get_mut("host").map(Value::take),
|
||
|
v.get_mut("port").map(Value::take),
|
||
3 years ago
|
v.get_mut("key").map(Value::take),
|
||
3 years ago
|
)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
3 years ago
|
impl AsRef<Map<String, Value>> for LspContent {
|
||
3 years ago
|
fn as_ref(&self) -> &Map<String, Value> {
|
||
|
&self.0
|
||
|
}
|
||
|
}
|
||
|
|
||
3 years ago
|
impl Deref for LspContent {
|
||
3 years ago
|
type Target = Map<String, Value>;
|
||
|
|
||
|
fn deref(&self) -> &Self::Target {
|
||
|
&self.0
|
||
|
}
|
||
|
}
|
||
|
|
||
3 years ago
|
impl DerefMut for LspContent {
|
||
3 years ago
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||
|
&mut self.0
|
||
|
}
|
||
|
}
|
||
|
|
||
3 years ago
|
impl fmt::Display for LspContent {
|
||
3 years ago
|
/// Outputs content in JSON form
|
||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
|
write!(
|
||
|
f,
|
||
|
"{}",
|
||
|
serde_json::to_string_pretty(self).map_err(|_| fmt::Error)?
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Debug, Display, Error, From)]
|
||
3 years ago
|
pub struct LspContentParseError(serde_json::Error);
|
||
3 years ago
|
|
||
3 years ago
|
impl From<LspContentParseError> for io::Error {
|
||
|
fn from(x: LspContentParseError) -> Self {
|
||
3 years ago
|
io::Error::new(io::ErrorKind::InvalidData, x)
|
||
|
}
|
||
|
}
|
||
|
|
||
3 years ago
|
impl FromStr for LspContent {
|
||
|
type Err = LspContentParseError;
|
||
3 years ago
|
|
||
|
/// Parses content in JSON form
|
||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
|
serde_json::from_str(s).map_err(From::from)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[cfg(test)]
|
||
|
mod tests {
|
||
|
use super::*;
|
||
|
use crate::net::SecretKey;
|
||
|
|
||
3 years ago
|
// 32-byte test hex key (64 hex characters)
|
||
|
const TEST_HEX_KEY: &str = "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD";
|
||
|
|
||
3 years ago
|
macro_rules! make_obj {
|
||
|
($($tail:tt)*) => {
|
||
|
match serde_json::json!($($tail)*) {
|
||
|
serde_json::Value::Object(x) => x,
|
||
|
x => panic!("Got non-object: {:?}", x),
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_display_should_output_header_and_content() {
|
||
|
let data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({"hello": "world"})),
|
||
3 years ago
|
};
|
||
|
|
||
|
let output = data.to_string();
|
||
|
assert_eq!(
|
||
|
output,
|
||
|
concat!(
|
||
|
"Content-Length: 123\r\n",
|
||
|
"Content-Type: some content type\r\n",
|
||
|
"\r\n",
|
||
|
"{\n",
|
||
|
" \"hello\": \"world\"\n",
|
||
|
"}",
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_from_buf_reader_should_be_successful_if_valid_data_received() {
|
||
|
let mut input = io::Cursor::new(concat!(
|
||
|
"Content-Length: 22\r\n",
|
||
|
"Content-Type: some content type\r\n",
|
||
|
"\r\n",
|
||
|
"{\n",
|
||
|
" \"hello\": \"world\"\n",
|
||
|
"}",
|
||
|
));
|
||
|
let data = LspData::from_buf_reader(&mut input).unwrap();
|
||
|
assert_eq!(data.header.content_length, 22);
|
||
|
assert_eq!(
|
||
|
data.header.content_type.as_deref(),
|
||
|
Some("some content type")
|
||
|
);
|
||
|
assert_eq!(data.content.as_ref(), &make_obj!({ "hello": "world" }));
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_from_buf_reader_should_fail_if_reach_eof_before_received_full_data() {
|
||
3 years ago
|
// No line termination
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new("Content-Length: 22")).unwrap_err();
|
||
3 years ago
|
assert!(
|
||
|
matches!(err, LspDataParseError::BadHeaderTermination),
|
||
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
3 years ago
|
|
||
3 years ago
|
// Header doesn't finish
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(concat!(
|
||
|
"Content-Length: 22\r\n",
|
||
|
"Content-Type: some content type\r\n",
|
||
|
)))
|
||
|
.unwrap_err();
|
||
|
assert!(matches!(err, LspDataParseError::UnexpectedEof), "{:?}", err);
|
||
|
|
||
|
// No content after header
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(concat!(
|
||
|
"Content-Length: 22\r\n",
|
||
|
"\r\n",
|
||
|
)))
|
||
|
.unwrap_err();
|
||
|
assert!(matches!(err, LspDataParseError::UnexpectedEof), "{:?}", err);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_from_buf_reader_should_fail_if_missing_proper_line_termination_for_header_field() {
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(concat!(
|
||
|
"Content-Length: 22\n",
|
||
|
"\r\n",
|
||
|
"{\n",
|
||
|
" \"hello\": \"world\"\n",
|
||
|
"}",
|
||
|
)))
|
||
|
.unwrap_err();
|
||
|
assert!(
|
||
|
matches!(err, LspDataParseError::BadHeaderTermination),
|
||
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_from_buf_reader_should_fail_if_bad_header_provided() {
|
||
|
// Invalid content length
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(concat!(
|
||
|
"Content-Length: -1\r\n",
|
||
|
"\r\n",
|
||
|
"{\n",
|
||
|
" \"hello\": \"world\"\n",
|
||
|
"}",
|
||
|
)))
|
||
|
.unwrap_err();
|
||
|
assert!(matches!(err, LspDataParseError::BadHeader(_)), "{:?}", err);
|
||
|
|
||
|
// Missing content length
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(concat!(
|
||
|
"Content-Type: some content type\r\n",
|
||
|
"\r\n",
|
||
|
"{\n",
|
||
|
" \"hello\": \"world\"\n",
|
||
|
"}",
|
||
|
)))
|
||
|
.unwrap_err();
|
||
|
assert!(matches!(err, LspDataParseError::BadHeader(_)), "{:?}", err);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_from_buf_reader_should_fail_if_bad_content_provided() {
|
||
|
// Not full content
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(concat!(
|
||
|
"Content-Length: 21\r\n",
|
||
|
"\r\n",
|
||
|
"{\n",
|
||
|
" \"hello\": \"world\"\n",
|
||
|
)))
|
||
|
.unwrap_err();
|
||
|
assert!(matches!(err, LspDataParseError::BadContent(_)), "{:?}", err);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn data_from_buf_reader_should_fail_if_non_utf8_data_encountered_for_content() {
|
||
|
// Not utf-8 content
|
||
|
let mut raw = b"Content-Length: 2\r\n\r\n".to_vec();
|
||
|
raw.extend(vec![0, 159]);
|
||
|
|
||
|
let err = LspData::from_buf_reader(&mut io::Cursor::new(raw)).unwrap_err();
|
||
|
assert!(matches!(err, LspDataParseError::BadInput(_)), "{:?}", err);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_succeed_if_valid_session_found_in_params() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let info = data.take_session_info().unwrap();
|
||
3 years ago
|
assert_eq!(
|
||
3 years ago
|
info,
|
||
|
SessionInfo {
|
||
3 years ago
|
host: String::from("some.host"),
|
||
|
port: 22,
|
||
3 years ago
|
key: SecretKey::from_slice(&hex::decode(TEST_HEX_KEY).unwrap()).unwrap(),
|
||
3 years ago
|
}
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_remove_session_parameters_if_successful() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let _ = data.take_session_info().unwrap();
|
||
3 years ago
|
assert_eq!(
|
||
|
data.content.as_ref(),
|
||
|
&make_obj!({
|
||
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {}
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_adjust_content_length_based_on_new_content_byte_length() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let _ = data.take_session_info().unwrap();
|
||
3 years ago
|
assert_eq!(data.header.content_length, data.content.to_string().len());
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_path_incomplete_to_session_params() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::MissingSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_missing_host_param() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::MissingSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_host_param_is_invalid() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": 1234,
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::InvalidSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_missing_port_param() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::MissingSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_port_param_is_invalid() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": "abcd",
|
||
3 years ago
|
"key": TEST_HEX_KEY
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::InvalidSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_missing_key_param() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::MissingSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_key_param_is_invalid() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
3 years ago
|
"key": 1234,
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::InvalidSessionInfoParams),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_missing_method_field() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY,
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::NotInitializeRequest),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
3 years ago
|
fn data_take_session_info_should_fail_if_method_field_is_not_initialize() {
|
||
3 years ago
|
let mut data = LspData {
|
||
3 years ago
|
header: LspHeader {
|
||
3 years ago
|
content_length: 123456,
|
||
|
content_type: Some(String::from("some content type")),
|
||
|
},
|
||
3 years ago
|
content: LspContent(make_obj!({
|
||
3 years ago
|
"method": "not initialize",
|
||
|
"params": {
|
||
|
"initializationOptions": {
|
||
|
"distant": {
|
||
|
"host": "some.host",
|
||
|
"port": 22,
|
||
3 years ago
|
"key": TEST_HEX_KEY,
|
||
3 years ago
|
}
|
||
|
}
|
||
|
}
|
||
|
})),
|
||
|
};
|
||
|
|
||
3 years ago
|
let err = data.take_session_info().unwrap_err();
|
||
3 years ago
|
assert!(
|
||
3 years ago
|
matches!(err, LspSessionInfoError::NotInitializeRequest),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn header_parse_should_fail_if_missing_content_length() {
|
||
|
let err = "Content-Type: some type\r\n\r\n"
|
||
3 years ago
|
.parse::<LspHeader>()
|
||
3 years ago
|
.unwrap_err();
|
||
|
assert!(
|
||
3 years ago
|
matches!(err, LspHeaderParseError::MissingContentLength),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn header_parse_should_fail_if_content_length_invalid() {
|
||
|
let err = "Content-Length: -1\r\n\r\n"
|
||
3 years ago
|
.parse::<LspHeader>()
|
||
3 years ago
|
.unwrap_err();
|
||
|
assert!(
|
||
3 years ago
|
matches!(err, LspHeaderParseError::InvalidContentLength(_)),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn header_parse_should_fail_if_receive_an_unexpected_header_field() {
|
||
|
let err = "Content-Length: 123\r\nUnknown-Field: abc\r\n\r\n"
|
||
3 years ago
|
.parse::<LspHeader>()
|
||
3 years ago
|
.unwrap_err();
|
||
|
assert!(
|
||
3 years ago
|
matches!(err, LspHeaderParseError::BadHeaderField),
|
||
3 years ago
|
"{:?}",
|
||
|
err
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn header_parse_should_succeed_if_given_valid_content_length() {
|
||
3 years ago
|
let header = "Content-Length: 123\r\n\r\n".parse::<LspHeader>().unwrap();
|
||
3 years ago
|
assert_eq!(header.content_length, 123);
|
||
|
assert_eq!(header.content_type, None);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn header_parse_should_support_optional_content_type() {
|
||
|
// Regular type
|
||
|
let header = "Content-Length: 123\r\nContent-Type: some content type\r\n\r\n"
|
||
3 years ago
|
.parse::<LspHeader>()
|
||
3 years ago
|
.unwrap();
|
||
|
assert_eq!(header.content_length, 123);
|
||
|
assert_eq!(header.content_type.as_deref(), Some("some content type"));
|
||
|
|
||
|
// Type with colons
|
||
|
let header = "Content-Length: 123\r\nContent-Type: some:content:type\r\n\r\n"
|
||
3 years ago
|
.parse::<LspHeader>()
|
||
3 years ago
|
.unwrap();
|
||
|
assert_eq!(header.content_length, 123);
|
||
|
assert_eq!(header.content_type.as_deref(), Some("some:content:type"));
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn header_display_should_output_header_fields_with_appropriate_line_terminations() {
|
||
|
// Without content type
|
||
3 years ago
|
let header = LspHeader {
|
||
3 years ago
|
content_length: 123,
|
||
|
content_type: None,
|
||
|
};
|
||
|
assert_eq!(header.to_string(), "Content-Length: 123\r\n\r\n");
|
||
|
|
||
|
// With content type
|
||
3 years ago
|
let header = LspHeader {
|
||
3 years ago
|
content_length: 123,
|
||
|
content_type: Some(String::from("some type")),
|
||
|
};
|
||
|
assert_eq!(
|
||
|
header.to_string(),
|
||
|
"Content-Length: 123\r\nContent-Type: some type\r\n\r\n"
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn content_parse_should_succeed_if_valid_json() {
|
||
3 years ago
|
let content = "{\"hello\": \"world\"}".parse::<LspContent>().unwrap();
|
||
3 years ago
|
assert_eq!(content.as_ref(), &make_obj!({"hello": "world"}));
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn content_parse_should_fail_if_invalid_json() {
|
||
|
assert!(
|
||
3 years ago
|
"not json".parse::<LspContent>().is_err(),
|
||
3 years ago
|
"Unexpectedly succeeded"
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn content_display_should_output_content_as_json() {
|
||
3 years ago
|
let content = LspContent(make_obj!({"hello": "world"}));
|
||
3 years ago
|
assert_eq!(content.to_string(), "{\n \"hello\": \"world\"\n}");
|
||
|
}
|
||
3 years ago
|
|
||
|
#[test]
|
||
|
fn content_convert_local_scheme_to_distant_should_convert_keys_and_values() {
|
||
|
let mut content = LspContent(make_obj!({
|
||
|
"distant://key1": "file://value1",
|
||
|
"file://key2": "distant://value2",
|
||
|
"key3": ["file://value3", "distant://value4"],
|
||
|
"key4": {
|
||
|
"distant://key5": "file://value5",
|
||
|
"file://key6": "distant://value6",
|
||
|
"key7": [
|
||
|
{
|
||
|
"distant://key8": "file://value8",
|
||
|
"file://key9": "distant://value9",
|
||
|
}
|
||
|
]
|
||
|
},
|
||
|
"key10": null,
|
||
|
"key11": 123,
|
||
|
"key12": true,
|
||
|
}));
|
||
|
|
||
|
content.convert_local_scheme_to_distant();
|
||
|
assert_eq!(
|
||
|
content.0,
|
||
|
make_obj!({
|
||
|
"distant://key1": "distant://value1",
|
||
|
"distant://key2": "distant://value2",
|
||
|
"key3": ["distant://value3", "distant://value4"],
|
||
|
"key4": {
|
||
|
"distant://key5": "distant://value5",
|
||
|
"distant://key6": "distant://value6",
|
||
|
"key7": [
|
||
|
{
|
||
|
"distant://key8": "distant://value8",
|
||
|
"distant://key9": "distant://value9",
|
||
|
}
|
||
|
]
|
||
|
},
|
||
|
"key10": null,
|
||
|
"key11": 123,
|
||
|
"key12": true,
|
||
|
})
|
||
|
);
|
||
|
}
|
||
|
|
||
|
#[test]
|
||
|
fn content_convert_distant_scheme_to_local_should_convert_keys_and_values() {
|
||
3 years ago
|
let mut content = LspContent(make_obj!({
|
||
3 years ago
|
"distant://key1": "file://value1",
|
||
|
"file://key2": "distant://value2",
|
||
|
"key3": ["file://value3", "distant://value4"],
|
||
|
"key4": {
|
||
|
"distant://key5": "file://value5",
|
||
|
"file://key6": "distant://value6",
|
||
|
"key7": [
|
||
|
{
|
||
|
"distant://key8": "file://value8",
|
||
|
"file://key9": "distant://value9",
|
||
|
}
|
||
|
]
|
||
|
},
|
||
|
"key10": null,
|
||
|
"key11": 123,
|
||
|
"key12": true,
|
||
|
}));
|
||
|
|
||
|
content.convert_distant_scheme_to_local();
|
||
|
assert_eq!(
|
||
|
content.0,
|
||
|
make_obj!({
|
||
|
"file://key1": "file://value1",
|
||
|
"file://key2": "file://value2",
|
||
|
"key3": ["file://value3", "file://value4"],
|
||
|
"key4": {
|
||
|
"file://key5": "file://value5",
|
||
|
"file://key6": "file://value6",
|
||
|
"key7": [
|
||
|
{
|
||
|
"file://key8": "file://value8",
|
||
|
"file://key9": "file://value9",
|
||
|
}
|
||
|
]
|
||
|
},
|
||
|
"key10": null,
|
||
|
"key11": 123,
|
||
|
"key12": true,
|
||
|
})
|
||
|
);
|
||
|
}
|
||
3 years ago
|
}
|