chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,920 @@
|
||||
//! The `ison` command converts between JSON and a compact ISON subset.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice,
|
||||
parse_format_choice, parse_input_format, print_quick_help_error, print_structured,
|
||||
read_existing_stdin_paths, should_read_stdin,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use serde_json::{Map, Number, Value, json};
|
||||
|
||||
const HELP: &str = "\
|
||||
Convert between JSON and ISON.
|
||||
|
||||
Usage:
|
||||
ison [OPTIONS] [PATH]
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--from <FORMAT> Force source syntax: auto, json, ison
|
||||
--to <FORMAT> Force target syntax: auto, json, ison
|
||||
--input-format <FORMAT> Override stdin path mode: auto, lines, jsonl
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
ISON block syntax:
|
||||
table.users
|
||||
id:int name:str active:bool
|
||||
1 Ada true
|
||||
|
||||
Examples:
|
||||
ison .\\fixtures\\json-family\\ison\\users.json
|
||||
ison --from ison --to json .\\fixtures\\json-family\\ison\\users.ison
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Syntax {
|
||||
Auto,
|
||||
Json,
|
||||
Ison,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
from: Syntax,
|
||||
to: Syntax,
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ResolvedSyntax {
|
||||
Json,
|
||||
Ison,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BlockKind {
|
||||
Table,
|
||||
Object,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FieldType {
|
||||
Int,
|
||||
Float,
|
||||
Bool,
|
||||
Str,
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Field {
|
||||
name: String,
|
||||
kind: FieldType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Token {
|
||||
value: String,
|
||||
quoted: bool,
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help, _)) => {
|
||||
print!("{HELP}");
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("ison {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut cli = Cli {
|
||||
common: CommonArgs::default(),
|
||||
from: Syntax::Auto,
|
||||
to: Syntax::Auto,
|
||||
path: None,
|
||||
};
|
||||
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
|
||||
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
|
||||
Long("input-format") => {
|
||||
let value = parser_value_string(&mut parser, "--input-format")?;
|
||||
cli.common.input_format = parse_input_format(&value)?;
|
||||
}
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
cli.common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("json") => cli.common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
|
||||
Long("color") => {
|
||||
let value = parser_value_string(&mut parser, "--color")?;
|
||||
cli.common.color = parse_color_choice(&value)?;
|
||||
}
|
||||
Long("quiet") => cli.common.quiet = true,
|
||||
Long("from") => {
|
||||
cli.from = parse_syntax("--from", &parser_value_string(&mut parser, "--from")?)?;
|
||||
}
|
||||
Long("to") => {
|
||||
cli.to = parse_syntax("--to", &parser_value_string(&mut parser, "--to")?)?;
|
||||
}
|
||||
ArgValue(path) => {
|
||||
if cli.path.replace(PathBuf::from(path)).is_some() {
|
||||
return Err(CliError::usage("ison accepts at most one explicit path"));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ParseOutcome::Run, cli))
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_syntax(flag: &str, value: &str) -> Result<Syntax, CliError> {
|
||||
match value {
|
||||
"auto" => Ok(Syntax::Auto),
|
||||
"json" => Ok(Syntax::Json),
|
||||
"ison" => Ok(Syntax::Ison),
|
||||
other => Err(CliError::usage(format!(
|
||||
"invalid {flag} value '{other}'; expected auto, json, or ison"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
let input = load_input(cli)?;
|
||||
let source = resolve_source(cli.from, &input);
|
||||
let target = resolve_target(cli.to, source);
|
||||
let value = match source {
|
||||
ResolvedSyntax::Json => serde_json::from_str::<Value>(input.trim())
|
||||
.map_err(|error| CliError::usage(format!("invalid JSON input: {error}")))?,
|
||||
ResolvedSyntax::Ison => decode_document(&input).map_err(CliError::usage)?,
|
||||
};
|
||||
|
||||
let (format, text) = match target {
|
||||
ResolvedSyntax::Json => {
|
||||
let text = serde_json::to_string_pretty(&value)
|
||||
.map_err(|error| CliError::runtime(format!("failed to render JSON: {error}")))?;
|
||||
("json", format!("{text}\n"))
|
||||
}
|
||||
ResolvedSyntax::Ison => ("ison", encode_document(&value).map_err(CliError::usage)?),
|
||||
};
|
||||
|
||||
match cli.common.render_mode() {
|
||||
RenderMode::Text => write_stdout(&text)?,
|
||||
RenderMode::Json | RenderMode::Toon => {
|
||||
print_structured(
|
||||
&json!({
|
||||
"format": format,
|
||||
"text": text,
|
||||
}),
|
||||
cli.common.render_mode(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
|
||||
fn load_input(cli: &Cli) -> Result<String, CliError> {
|
||||
if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) {
|
||||
let mut buffer = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buffer)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
||||
if !buffer.is_empty() {
|
||||
if cli.common.input_format != InputFormat::Jsonl
|
||||
&& let Some(paths) =
|
||||
read_existing_stdin_paths(&buffer, cli.common.input_format, "ison")?
|
||||
{
|
||||
return read_single_stdin_path(&paths, "ison");
|
||||
}
|
||||
return Ok(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(path) = &cli.path else {
|
||||
return Err(CliError::usage(
|
||||
"provide one JSON/ISON path or pipe input into stdin",
|
||||
));
|
||||
};
|
||||
|
||||
let path = common::require_exactly_one_input_path(
|
||||
&common::expand_input_patterns(std::slice::from_ref(path), "ison")?,
|
||||
"ison",
|
||||
)?;
|
||||
fs::read_to_string(&path)
|
||||
.map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display())))
|
||||
}
|
||||
|
||||
fn read_single_stdin_path(paths: &[PathBuf], command_name: &str) -> Result<String, CliError> {
|
||||
if paths.len() != 1 {
|
||||
return Err(CliError::usage(format!(
|
||||
"{command_name} accepts exactly one stdin path, got {}",
|
||||
paths.len()
|
||||
)));
|
||||
}
|
||||
fs::read_to_string(&paths[0]).map_err(|error| {
|
||||
CliError::runtime(format!("failed to read {}: {error}", paths[0].display()))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_source(source: Syntax, input: &str) -> ResolvedSyntax {
|
||||
match source {
|
||||
Syntax::Json => ResolvedSyntax::Json,
|
||||
Syntax::Ison => ResolvedSyntax::Ison,
|
||||
Syntax::Auto => {
|
||||
if serde_json::from_str::<Value>(input.trim()).is_ok() {
|
||||
ResolvedSyntax::Json
|
||||
} else {
|
||||
ResolvedSyntax::Ison
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn resolve_target(target: Syntax, source: ResolvedSyntax) -> ResolvedSyntax {
|
||||
match target {
|
||||
Syntax::Json => ResolvedSyntax::Json,
|
||||
Syntax::Ison => ResolvedSyntax::Ison,
|
||||
Syntax::Auto => match source {
|
||||
ResolvedSyntax::Json => ResolvedSyntax::Ison,
|
||||
ResolvedSyntax::Ison => ResolvedSyntax::Json,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn write_stdout(text: &str) -> Result<(), CliError> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))
|
||||
}
|
||||
|
||||
/// Encodes one JSON document into deterministic ISON block text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the document cannot be represented by the v1 compact
|
||||
/// subset, for example a top-level non-object or an empty array whose fields
|
||||
/// cannot be inferred.
|
||||
pub fn encode_document(value: &Value) -> Result<String, String> {
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| "ISON v1 expects a top-level JSON object".to_string())?;
|
||||
let mut output = String::with_capacity(object.len().saturating_mul(128));
|
||||
|
||||
for (name, child) in object {
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
match child {
|
||||
Value::Array(items) => encode_table_block(name, items, &mut output)?,
|
||||
Value::Object(map) => encode_object_block(name, map, &mut output)?,
|
||||
scalar => {
|
||||
let mut map = Map::new();
|
||||
map.insert("value".to_string(), scalar.clone());
|
||||
encode_object_block(name, &map, &mut output)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.push('\n');
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_table_block(name: &str, items: &[Value], output: &mut String) -> Result<(), String> {
|
||||
let Some(first) = items.first() else {
|
||||
return Err(format!(
|
||||
"table '{name}' is empty; fields cannot be inferred"
|
||||
));
|
||||
};
|
||||
let first_object = first
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("table '{name}' expects JSON object rows"))?;
|
||||
let fields = infer_fields(first_object);
|
||||
output.push_str("table.");
|
||||
output.push_str(name);
|
||||
output.push('\n');
|
||||
push_fields(output, &fields);
|
||||
output.push('\n');
|
||||
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let object = item
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("table '{name}' row {} is not an object", index + 1))?;
|
||||
if index > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
push_row(output, object, &fields);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_object_block(
|
||||
name: &str,
|
||||
object: &Map<String, Value>,
|
||||
output: &mut String,
|
||||
) -> Result<(), String> {
|
||||
if object.is_empty() {
|
||||
return Err(format!(
|
||||
"object '{name}' is empty; fields cannot be inferred"
|
||||
));
|
||||
}
|
||||
let fields = infer_fields(object);
|
||||
output.push_str("object.");
|
||||
output.push_str(name);
|
||||
output.push('\n');
|
||||
push_fields(output, &fields);
|
||||
output.push('\n');
|
||||
push_row(output, object, &fields);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encodes one JSON object into an ISONL record line.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when `record` is not a JSON object or has no fields.
|
||||
pub fn encode_record(record: &Value, name: &str) -> Result<String, String> {
|
||||
let object = record
|
||||
.as_object()
|
||||
.ok_or_else(|| "ISONL v1 expects each JSONL line to be an object".to_string())?;
|
||||
if object.is_empty() {
|
||||
return Err("ISONL v1 cannot infer fields from an empty object".to_string());
|
||||
}
|
||||
let fields = infer_fields(object);
|
||||
let mut output = String::with_capacity(name.len() + fields.len().saturating_mul(24) + 16);
|
||||
output.push_str("object.");
|
||||
output.push_str(name);
|
||||
output.push('|');
|
||||
push_fields(&mut output, &fields);
|
||||
output.push('|');
|
||||
push_row(&mut output, object, &fields);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn infer_fields(object: &Map<String, Value>) -> Vec<Field> {
|
||||
object
|
||||
.iter()
|
||||
.map(|(name, value)| Field {
|
||||
name: name.clone(),
|
||||
kind: infer_type(value),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn infer_type(value: &Value) -> FieldType {
|
||||
match value {
|
||||
Value::Bool(_) => FieldType::Bool,
|
||||
Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int,
|
||||
Value::Number(_) => FieldType::Float,
|
||||
Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str,
|
||||
Value::Null => FieldType::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_fields(output: &mut String, fields: &[Field]) {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(' ');
|
||||
}
|
||||
output.push_str(&field.name);
|
||||
output.push(':');
|
||||
output.push_str(field.kind.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_row(output: &mut String, object: &Map<String, Value>, fields: &[Field]) {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(' ');
|
||||
}
|
||||
push_value(output, object.get(&field.name).unwrap_or(&Value::Null));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_value(output: &mut String, value: &Value) {
|
||||
match value {
|
||||
Value::Null => output.push_str("null"),
|
||||
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => {
|
||||
let _ = write!(output, "{value}");
|
||||
}
|
||||
Value::String(value) => push_string(output, value),
|
||||
Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_string(output: &mut String, value: &str) {
|
||||
if value.is_empty()
|
||||
|| value
|
||||
.chars()
|
||||
.any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|'))
|
||||
{
|
||||
output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()));
|
||||
} else {
|
||||
output.push_str(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes ISON block text into a JSON document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error with a line number when block headers, field definitions,
|
||||
/// or row values are malformed.
|
||||
pub fn decode_document(input: &str) -> Result<Value, String> {
|
||||
let lines = input
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter_map(|(index, line)| {
|
||||
let trimmed = line.trim();
|
||||
(!trimmed.is_empty()).then_some((index + 1, trimmed))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut index = 0;
|
||||
let mut root = Map::new();
|
||||
|
||||
while index < lines.len() {
|
||||
let (header_line, header) = lines[index];
|
||||
let (kind, name) = parse_header(header, header_line)?;
|
||||
index += 1;
|
||||
let Some((fields_line, fields_text)) = lines.get(index).copied() else {
|
||||
return Err(format!("line {header_line}: missing field definition"));
|
||||
};
|
||||
let fields = parse_fields(fields_text, fields_line)?;
|
||||
index += 1;
|
||||
let mut rows = Vec::new();
|
||||
|
||||
while let Some((line_number, line)) = lines.get(index).copied() {
|
||||
if looks_like_header(line) {
|
||||
break;
|
||||
}
|
||||
rows.push(parse_row(line, line_number, &fields)?);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return Err(format!("line {header_line}: block '{name}' has no rows"));
|
||||
}
|
||||
let value = match kind {
|
||||
BlockKind::Table => Value::Array(rows.into_iter().map(Value::Object).collect()),
|
||||
BlockKind::Object => Value::Object(rows.remove(0)),
|
||||
};
|
||||
root.insert(name, value);
|
||||
}
|
||||
|
||||
Ok(Value::Object(root))
|
||||
}
|
||||
|
||||
/// Decodes one ISONL record line into a JSON object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error with `line_number` when the ISONL record is malformed.
|
||||
pub fn decode_record_line(line: &str, line_number: usize) -> Result<Value, String> {
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let Some(header) = parts.next() else {
|
||||
return Err(format!(
|
||||
"line {line_number}: ISONL record expects 3 pipe-delimited segments"
|
||||
));
|
||||
};
|
||||
let Some(fields_text) = parts.next() else {
|
||||
return Err(format!(
|
||||
"line {line_number}: ISONL record expects 3 pipe-delimited segments"
|
||||
));
|
||||
};
|
||||
let Some(row_text) = parts.next() else {
|
||||
return Err(format!(
|
||||
"line {line_number}: ISONL record expects 3 pipe-delimited segments"
|
||||
));
|
||||
};
|
||||
parse_header(header.trim(), line_number)?;
|
||||
let fields = parse_fields(fields_text.trim(), line_number)?;
|
||||
let row = parse_row(row_text.trim(), line_number, &fields)?;
|
||||
Ok(Value::Object(row))
|
||||
}
|
||||
|
||||
fn parse_header(line: &str, line_number: usize) -> Result<(BlockKind, String), String> {
|
||||
let Some((kind, name)) = line.split_once('.') else {
|
||||
return Err(format!(
|
||||
"line {line_number}: expected block header kind.name"
|
||||
));
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(format!("line {line_number}: block name must not be empty"));
|
||||
}
|
||||
let kind = match kind {
|
||||
"table" => BlockKind::Table,
|
||||
"object" => BlockKind::Object,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"line {line_number}: unsupported block kind '{other}'; expected table or object"
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok((kind, name.to_string()))
|
||||
}
|
||||
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
line.starts_with("table.") || line.starts_with("object.")
|
||||
}
|
||||
|
||||
fn parse_fields(line: &str, line_number: usize) -> Result<Vec<Field>, String> {
|
||||
let fields = line
|
||||
.split_whitespace()
|
||||
.map(|part| parse_field(part, line_number))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if fields.is_empty() {
|
||||
Err(format!("line {line_number}: expected at least one field"))
|
||||
} else {
|
||||
Ok(fields)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_field(part: &str, line_number: usize) -> Result<Field, String> {
|
||||
let Some((name, kind)) = part.split_once(':') else {
|
||||
return Err(format!(
|
||||
"line {line_number}: field '{part}' must use name:type syntax"
|
||||
));
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(format!("line {line_number}: field name must not be empty"));
|
||||
}
|
||||
Ok(Field {
|
||||
name: name.to_string(),
|
||||
kind: parse_field_type(kind, line_number)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_field_type(kind: &str, line_number: usize) -> Result<FieldType, String> {
|
||||
match kind {
|
||||
"int" => Ok(FieldType::Int),
|
||||
"float" => Ok(FieldType::Float),
|
||||
"bool" => Ok(FieldType::Bool),
|
||||
"str" | "string" => Ok(FieldType::Str),
|
||||
"null" => Ok(FieldType::Null),
|
||||
other => Err(format!(
|
||||
"line {line_number}: unsupported field type '{other}'"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_row(
|
||||
line: &str,
|
||||
line_number: usize,
|
||||
fields: &[Field],
|
||||
) -> Result<Map<String, Value>, String> {
|
||||
let tokens = tokenize_values(line, line_number)?;
|
||||
if tokens.len() != fields.len() {
|
||||
return Err(format!(
|
||||
"line {line_number}: expected {} values, got {}",
|
||||
fields.len(),
|
||||
tokens.len()
|
||||
));
|
||||
}
|
||||
fields
|
||||
.iter()
|
||||
.zip(tokens)
|
||||
.map(|(field, token)| {
|
||||
parse_value(&token, field.kind, line_number).map(|value| (field.name.clone(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tokenize_values(line: &str, line_number: usize) -> Result<Vec<Token>, String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = line.char_indices().peekable();
|
||||
|
||||
while let Some((_, character)) = chars.peek().copied() {
|
||||
if character.is_whitespace() {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
if character == '"' {
|
||||
let start = chars.next().map_or(0, |(index, _)| index);
|
||||
let mut escaped = false;
|
||||
let mut end = None;
|
||||
for (index, current) in chars.by_ref() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if current == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if current == '"' {
|
||||
end = Some(index + current.len_utf8());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(end) = end else {
|
||||
return Err(format!("line {line_number}: unterminated string value"));
|
||||
};
|
||||
tokens.push(Token {
|
||||
value: line[start..end].to_string(),
|
||||
quoted: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let start = chars.next().map_or(0, |(index, _)| index);
|
||||
let mut end = line.len();
|
||||
while let Some((index, current)) = chars.peek().copied() {
|
||||
if current.is_whitespace() {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
tokens.push(Token {
|
||||
value: line[start..end].to_string(),
|
||||
quoted: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn parse_value(token: &Token, kind: FieldType, line_number: usize) -> Result<Value, String> {
|
||||
match kind {
|
||||
FieldType::Int => parse_number_value(token, line_number),
|
||||
FieldType::Float => token
|
||||
.value
|
||||
.parse::<f64>()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"line {line_number}: invalid float '{}': {error}",
|
||||
token.value
|
||||
)
|
||||
})
|
||||
.and_then(|value| {
|
||||
Number::from_f64(value)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| format!("line {line_number}: float must be finite"))
|
||||
}),
|
||||
FieldType::Bool => token
|
||||
.value
|
||||
.parse::<bool>()
|
||||
.map(Value::Bool)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"line {line_number}: invalid bool '{}': {error}",
|
||||
token.value
|
||||
)
|
||||
}),
|
||||
FieldType::Str => {
|
||||
if token.quoted {
|
||||
serde_json::from_str::<String>(&token.value)
|
||||
.map(Value::String)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"line {line_number}: invalid quoted string '{}': {error}",
|
||||
token.value
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Ok(Value::String(token.value.clone()))
|
||||
}
|
||||
}
|
||||
FieldType::Null => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number_value(token: &Token, line_number: usize) -> Result<Value, String> {
|
||||
if let Ok(value) = token.value.parse::<i64>() {
|
||||
return Ok(Value::Number(Number::from(value)));
|
||||
}
|
||||
token
|
||||
.value
|
||||
.parse::<u64>()
|
||||
.map(Number::from)
|
||||
.map(Value::Number)
|
||||
.map_err(|error| format!("line {line_number}: invalid int '{}': {error}", token.value))
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Int => "int",
|
||||
Self::Float => "float",
|
||||
Self::Bool => "bool",
|
||||
Self::Str => "str",
|
||||
Self::Null => "null",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_cli_covers_flags_and_rejects_duplicate_paths() {
|
||||
let (outcome, cli) = parse_cli_from([
|
||||
"ison",
|
||||
"--from",
|
||||
"json",
|
||||
"--to",
|
||||
"ison",
|
||||
"--format",
|
||||
"json",
|
||||
"--input-format",
|
||||
"lines",
|
||||
"input.json",
|
||||
])
|
||||
.expect("valid cli");
|
||||
|
||||
assert_eq!(outcome, ParseOutcome::Run);
|
||||
assert_eq!(cli.from, Syntax::Json);
|
||||
assert_eq!(cli.to, Syntax::Ison);
|
||||
assert_eq!(cli.common.render_mode(), RenderMode::Json);
|
||||
assert_eq!(cli.common.input_format, InputFormat::Lines);
|
||||
assert_eq!(cli.path, Some(PathBuf::from("input.json")));
|
||||
|
||||
assert!(parse_cli_from(["ison", "--from", "yaml"]).is_err());
|
||||
assert!(parse_cli_from(["ison", "a.json", "b.json"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_document_roundtrips_tables_objects_and_scalars() {
|
||||
let value = json!({
|
||||
"users": [
|
||||
{"id": 1, "name": "Ada Lovelace", "active": true},
|
||||
{"id": 2, "name": "Grace", "active": false}
|
||||
],
|
||||
"meta": {"score": 1.5, "note": "pipe|quote\"", "missing": null},
|
||||
"count": 2
|
||||
});
|
||||
|
||||
let encoded = encode_document(&value).expect("encode document");
|
||||
assert!(encoded.contains("table.users"));
|
||||
assert!(encoded.contains("object.meta"));
|
||||
assert!(encoded.contains("object.count"));
|
||||
|
||||
let decoded = decode_document(&encoded).expect("decode document");
|
||||
assert_eq!(
|
||||
decoded,
|
||||
json!({
|
||||
"users": value["users"].clone(),
|
||||
"meta": value["meta"].clone(),
|
||||
"count": {"value": 2}
|
||||
})
|
||||
);
|
||||
assert_eq!(resolve_source(Syntax::Auto, &encoded), ResolvedSyntax::Ison);
|
||||
assert_eq!(
|
||||
resolve_target(Syntax::Auto, ResolvedSyntax::Ison),
|
||||
ResolvedSyntax::Json
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_lines_handle_quoted_values_and_type_errors() {
|
||||
let record = json!({
|
||||
"id": 7,
|
||||
"name": "Ada Byron",
|
||||
"active": true,
|
||||
"payload": {"role": "math"}
|
||||
});
|
||||
|
||||
let encoded = encode_record(&record, "user").expect("encode record");
|
||||
assert!(encoded.starts_with("object.user|"));
|
||||
let decoded = decode_record_line(&encoded, 4).expect("decode record");
|
||||
assert_eq!(
|
||||
decoded,
|
||||
json!({
|
||||
"id": 7,
|
||||
"name": "Ada Byron",
|
||||
"active": true,
|
||||
"payload": "{\"role\":\"math\"}"
|
||||
})
|
||||
);
|
||||
|
||||
let bad = decode_record_line("object.user|id:int active:bool|oops maybe", 9)
|
||||
.expect_err("invalid fields should fail");
|
||||
assert!(bad.contains("line 9"));
|
||||
assert_eq!(
|
||||
decode_record_line("object.counter|value:int|18446744073709551615", 10)
|
||||
.expect("u64 int")
|
||||
.get("value"),
|
||||
Some(&Value::Number(Number::from(u64::MAX)))
|
||||
);
|
||||
assert!(encode_record(&json!([]), "record").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_reports_malformed_headers_fields_and_rows() {
|
||||
assert!(decode_document("plain\nid:int\n1\n").is_err());
|
||||
assert!(decode_document("object.\nid:int\n1\n").is_err());
|
||||
assert!(decode_document("object.user\nid:weird\n1\n").is_err());
|
||||
assert!(decode_document("object.user\nid:int name:str\n1\n").is_err());
|
||||
assert!(decode_document("object.user\nid:int\nnot-an-int\n").is_err());
|
||||
assert!(decode_document("object.user\nname:str\n\"unterminated\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reads_path_inputs_and_supports_wrapper_modes() {
|
||||
let directory = tempdir().expect("tempdir");
|
||||
let json_path = directory.path().join("users.json");
|
||||
fs::write(
|
||||
&json_path,
|
||||
r#"{"users":[{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]}"#,
|
||||
)
|
||||
.expect("json fixture");
|
||||
|
||||
let (_, encode_cli) = parse_cli_from([
|
||||
"ison",
|
||||
"--from",
|
||||
"json",
|
||||
"--to",
|
||||
"ison",
|
||||
"--json",
|
||||
json_path.to_str().expect("utf8 path"),
|
||||
])
|
||||
.expect("encode cli");
|
||||
assert_eq!(run(&encode_cli).expect("encode run"), ExitCode::Success);
|
||||
|
||||
let ison_path = directory.path().join("users.ison");
|
||||
fs::write(&ison_path, "table.users\nid:int name:str\n1 Ada\n2 Grace\n")
|
||||
.expect("ison fixture");
|
||||
let (_, decode_cli) = parse_cli_from([
|
||||
"ison",
|
||||
"--from",
|
||||
"ison",
|
||||
"--to",
|
||||
"json",
|
||||
"--toon",
|
||||
ison_path.to_str().expect("utf8 path"),
|
||||
])
|
||||
.expect("decode cli");
|
||||
assert_eq!(run(&decode_cli).expect("decode run"), ExitCode::Success);
|
||||
|
||||
let (_, missing_cli) = parse_cli_from(["ison"]).expect("missing input cli");
|
||||
assert!(run(&missing_cli).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for `ison`.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(ison::main_entry());
|
||||
}
|
||||
Reference in New Issue
Block a user