Show HN: Jb / json.bash – Command-line tool (and bash library) that creates JSON (github.com)
I wrote this because I wanted a robust and ergonomic way to create ad-hoc JSON data from the command line and scripts. I wanted errors to not pass silently, not coerce data types, not put secrets into argv. I wanted to leverage shell features/patterns like process substitution, environment variables, reading/streaming from files and null-terminated data.
If you know of the jo program, jb is similar, but type-safe by default and more flexible. jo coerces types, using flags like -n to coerce to a specific type (number for -n), without failing if the input is invalid. jb encodes values as strings by default, requiring type annotations to parse & encode values as a specific type (failing if the value is invalid).
If you know jq, jb is complementary in that jq is great at transforming data already in JSON format, but it's fiddly to get non-JSON data into jq. In contrast, jb is good at getting unstructured data from arguments, environment variables and files into JSON (so that jq could use it), but jb cannot do any transformation of data, only parsing & encoding into JSON types.
I feel rather guilty about having written this in bash. It's something of a boiled frog story. I started out just wanting to encode JSON strings from a shell script, without dependencies, with the intention of piping them into jq. After a few trials I was able to encode JSON strings in bash with surprising performance, using array operations to encode multiple strings at once. It grew from there into a complete tool. I'd certainly not choose bash if I was starting from scratch now...
55 comments
[ 4.5 ms ] story [ 190 ms ] threadNormally, detecting errors on the other end of a pipe requires care in a shell environment (e.g. retrospectively checking PIPESTATUS). I used an approach I've called Stream Poisoning. It takes advantage of the fact that control characters are never present in valid JSON. When jb fails to encode JSON, it emits a Cancel control character[1] on stdout. When jb encounters such a character in an input, it can tell the input it's reading from is truncated/erroneous. This avoids the typical problem of a pipe silently being read as an empty file.
I've got a page explaining this with some examples here: https://github.com/h4l/json.bash/blob/main/docs/stream-poiso... I can imagine using control characters in a text stream being rather controversial, but I feel it works quite well in practice.
[1]: https://en.wikipedia.org/wiki/Cancel_character
For example `jb | jq`, where jq or a similar program discards the cancel character.
(Away from pc, unable to check right now.)
https://github.com/pmarreck/tinytestlib
In fact, I was thinking about such a syntax recently. I am writing a tool which lets you call functions in Python modules from the command line. At first, I thought I need to define the argument types on the command line. But then I decided it is more convenient to use inspection and auto-convert the values to the needed types.
Incidentally, this syntax shows a notation that is clearly superior to json (at least for non-nested stuff). If all you need is this, you'd be better off by avoiding json altogether.
[Rant: if json is so unergonomic that people keep inventing alternatives like this syntax and stuff like "gron" to de-jsonise their lives, maybe using json was always a bad idea, after all... I guess in a decade everybody will look at json with the same disdain as we do XML today.]
I don’t see that at all? Why is `n:number=1` superior to `{n:1}`? If anything, CLI commands are awful for anything other than strings.
I’m sure you can do similar things in other modern shells too. So the real problem is that people are stuck on the constraints of 1970s command lines.
The same using jo would be like this, which I find harder to type and remember:
Notice that surname comes out as the empty string though, I think this must be a bug in jo!Linux: what if everything was a file?
Soon we might have...
Mong/Os: what if everything was JSON?
YiAM/OS: YiAM/OS is ANOTHER MARKUP OPERATING SYSTEM... would come out shortly thereafter...
I like JSON and getting in the terminal is a challenge - GOOD JOB!
However, I think in your case the rationale in the performance section of your Readme totally makes sense, and every single use case I can think of for this would prioritise minimal latency over increased throughput. I've seen init containers that would execute probably 100x faster with this for the exact reasons you point out. I'm quite curious as to what you would you choose instead of bash if you were starting from scratch now?
FYI Shellcheck has a couple of superficial nits that you might wanna address (happy to send a PR). And your Readme is great.
If I started from scratch now I'd use a compiled language that could produce a single static binary and start with really low latency. I'm pretty sure jo must not be tuned for startup time, if they optimised that they must be able get it way faster than bash can start and parse json.bash. I was pretty surprised that bash can startup faster!
The codebase is basically at the limit of what I'd want to do with bash, but there are features I could add if it was in a proper programming language. e.g. validating :int number types, pretty-printing output, not needing the :raw type to stream JSON input.
Thanks for the heads up on Shellcheck, I'd be happy to take a PR if you'd like to.
[1] https://github.com/jpmens/jo
Still, bash can try to keep up using json.bash. :)
... is splatting the greeting associative array entries into the object created by the json call.Without the ... the greeting would be a nested object. Probably more clear with multiple entries:
Vs:For good measure, this is how you might do the same with jb:
Alternatively, using the :{} object entry syntax:This example uses the pattern of setting an out=varname when calling a json function, the encoded JSON goes into $varname variable. This pattern avoids the overhead of forking processes (e.g. subshells) when generating JSON.
Otherwise you can use the more normal approach of jb writing to stdout, and capturing the output stream.
> jshn (JSON SHell Notation), a small utility and shell library for parsing and generating JSON data
A man of culture I see.
This looks really useful where you don't want to introduce another scripting VM just to spit out some JSON, i.e I have used Ruby a lot for this in the past.
I can see myself using this in container init scripts and other very low dep environments to format config files from env vars etc.
This is just the kind of use case I had in mind. Something I've considered is publishing a mini version with only the json.encode_string function, as that's enough to create an array of JSON-encoded strings and use a hard-coded template with printf to insert the JSON string values.
That would be a fraction of the overall json.bash file size.
As a user, I’m fine with embedding a reasonably small VM to handle the configs; disk space is cheap. Better yet would be a compiled binary that handles it, but that feels like asking a lot of maintainers.
There’s a lot of surface area for someone to mis-quote stuff in their environment and generate unintelligible bash errors.
Or that may be just me; I hate bash in general, so maybe it’s just that bleeding over.
Can you give a concrete example of when this is the sanest option?
Second is situations where you'd rather not add an additional dependency, but bash is pretty much a given. For example, CI environments, scripts in dev environments, container entrypoints. Or things that area already written in bash.
I don't advocate writing massive programs in bash, for sure it's better to turn to a proper language before things get hairy. But bash is just really ubiquitous, and most people who do any UNIX work will be able to deal with a bit of shell script.
Is this tool not an additional dependency?
> But bash is just really ubiquitous
Biggest crime of the Unix world probably.
It is, but if you already have bash, adding another shell script isn't much of a jump. e.g. I'd feel OK about committing jb to another repo for use from a .envrc file to set up an environment, whereas committing a binary would not feel good.
> Biggest crime of the Unix world probably.
Sorry if I'm perpetuating this! :) My take is that problem is not with bash, the problem is that it's hard for more advanced tools to replace it.
"Dependency" generally means "external dependency".
It's only a dependency if your solution's build process fetches it from its upstream repo. (Or worse: it's just mentioned in some manual build instructions as one of the things your solution needs.)
This is small enough to copy into the source tree of your solution, in which case it's no longer a dependency.
But for when you don't want an extra dependency, awk and perl are better than bash and just about as ubiquitous. (I might dare to say more ubiquitous, since MacOS in particular ships with an ancient version of bash that can't even use this jb tool. But the versions of awk and perl it comes with are fine.)
https://github.com/h4l/json.bash/blob/main/json.bash
You've boiled it down to a set of very elegant constructs. Respect. Thank you @h4l, this is badass.
I hope you follow up with a golang or rust implementation, that would really be something else.
p.s. I noticed the following odd behaviors with escaping delimiters (e.g. "="), is there a way to get an un-escaped equal sign as the trailing part of a key or leading part of a value?
I definitely like the idea of a goland/rust implementation, there are certainly things I could improve.
So the argument syntax escapes by repeating a character rather than backslash. I chose this because with backslashes escapes it would be unclear whether a backslash was in the shell syntax or the jb syntax, and users may end up needing to double escape backslashes, which is no fun! Whereas a shell will always ignore two copies of a character like =:@.
The downside of double-escaping is that the syntax can be ambiguous, so sometimes you need to include the middle type marker to disambiguate the key from the value. But the type can be empty, so just : works:
In the key part, the first = begins the key, the == following are an escaped =. The first = following the : marks the value, and everything after is not parsed, so =hi= is literal.When you have reserved characters in keys/values (especially if they're dynamic), it's easiest to store the values in variables and reference them with @var syntax:
jb doesn't have high-level knowledge of other formats, it can read from common shell data sources, like command-line arguments, environment variables and files. It gives you ways to pull several of these sources into a single JSON object.
jb understands some simple/general formats commonly used in shell environments:
- key=value pairs (e.g. environment variable declarations, like the `env` program prints
- delimited lists, like a,b,c,d; (but any character can be the delimiter) including null-delimited (commonly used to separate lists of file paths)
- JSON itself — jb can validate and merge together arrays and objects
You can use these simple sources to build up a more complex structure, e.g. using a pipeline of other command line tools to generate null-delimited data, or envar declarations, then consuming the program's output via process substitution <(...). (See the section in the README that explains process substitution if you're not familiar, it's really powerful.)
So jb is more suited to creating ad-hoc JSON for specific tasks. If you had both jc and jb available and jc could read the source you need, you'd prefer jc.