Ask HN: Let's build Checkstyle for Bash?
Shellcheck: https://github.com/koalaman/shellcheck
From Asynchronous Lint Engine (ALE): https://github.com/dense-analysis/ale/blob/master/supported-...
- bashate: https://github.com/openstack/bashate
- cspell: https://github.com/streetsidesoftware/cspell/tree/main/packa...
- Bash Language Server: https://github.com/bash-lsp/bash-language-server
- shell -n flag: https://www.gnu.org/software/bash/manual/bash.html#index-set
- sh(shfmt): https://github.com/mvdan/sh
- shdoc: https://github.com/reconquest/shdoc
From this stack post [1]:
- checkbashisms: http://man.he.net/man1/checkbashisms
- shlint: https://github.com/duggan/shlint (archived)
Prettier: https://marketplace.visualstudio.com/items?itemName=esbenp.p...
Within all these linters and auto-formatters I did not find checks that enforce, for example, the Function Comments of the Shell Style Guide by Google:
All function comments should describe the intended API behaviour using:
Description of the function.
Globals: List of global variables used and modified.
Arguments: Arguments taken.
Outputs: Output to STDOUT or STDERR.
Returns: Returned values other than the default exit status of the last command run.
Hence, I thought we could make a Bash linting tool that verifies compliance with the Shell Style Guide by Google. To do so, a brief start was made here [2]. It identifies/lists elements in that style guide that may be verified automatically. Since Bash has been around longer than me, I think there may be some people better suited for the development of this enhanced linter. Hence, I thought it might be wise, for impact and usability, to share this idea here.What do you say, HN?
[0]: https://google.github.io/styleguide/shellguide.html
[1]: https://stackoverflow.com/questions/3668665/is-there-a-stati...
70 comments
[ 1.6 ms ] story [ 142 ms ] threadPOSIX mistake number 1: Bash isn't available everywhere, even when restricted to Linux. Even Debian avoids bash for a good reason (Bash is slower than most POSIX shell implementations), although it's installed by default for convenience.
:-/
Things like docker images and nix builds are sold as huge improvements on this chicken-egg problem, at least from a "keep it all in one language" perspective, but only if you consider a docker script (mostly bash) or a nix config truly not the same thing as a shell script that more or less initiates the same environment.
docker/nix abstraction layers have advantages but aren't always as portable as a shell script. If you need a docker engine, I think you'll still need a bash script to install that...
Nix is possibly even more "single command", often just a curl if i'm not mistaken, but still requires config and shipping a build somewhere, and you still need to run the `curl` command in a shell somehow. i don't see how "just use python" will ever fix the init chicken-egg problem.
- Self contained in one file
- Easily edited with vi/nano (i.e. through a console ssh session)
- Readily available on most Linux base installations
- No crazy runtime installation requirements
Edit: formatting)
How are you gonna handle installing that beautifulsoup dependency in a way that does not bork the OS python deps?
Are you gonna use `python -m venv` or `poetry` or `pipenv`?
If it is for work: either Bash or whatever that companies preferred software development language (over the last ~20 years that's included: Pascal, Visual Basic, PL/SQL, Perl, PHP, Python, Typescript and others I've likely forgotten. Different companies will have different preferences)
If performance matters then these days Go is my "goto" language because it's a good compromise in terms of developer productivity and application performance. But I've used Java, C and C++ too. Depending on the particular problem I'm trying to solve.
...and if you ask me again in 5 years then the answer will be completely different again.
The sort answer is: there's no such thing as a single answer. Everyone will have their own preferences and business requirements.
It depends on what I'm doing but usually Go if an application requires any longevity at all.
I use Python or Java for things that require more dynamic typing. The main usecase here is big data or to prototype applications.
Bash I usually reserve for very small tasks. When I bootstrap services in containers, you'll usually find some Bash. A whole CLI written in Bash? I would not approve that PR. Bash lacks variable scoping for the most part, the ways in which you can implement it are hacky and non-obvious. Bash's syntax and the way it behaves varies by system, even installer scripts have to account for this. As I'm typing this, I realize the Bash code I write generally follows the same testing and constraints as the Makefiles I write.
I don't think I really understand the requirement for "a single document". This departs from programming practices I've built up over the years. When I come across large multi-thousand LOC documents (in any language, much less Bash) I'm usually concerned.
Those tasks that I use bash for are usually one-offs, and live on the server they are used on.
Thus, it should be easy for the next admin/Dev to find it and understand/fix/extend it.
Of course, if there is a more advanced task those requirements fall short and there should be a proper Dev env with source control, tests, etc.
For example: https://github.com/nickjj/docker-flask-example/blob/main/run
In a bigger project this file tends to grow quite large, even when you have the power of a programming language available to you there's plenty of tasks where it makes sense to keep it as a shell script. Now imagine you work on a team where other folks want to contribute new functionality, it's important to have automated tools to help stick with a consistent style, just like you would want to use Black with Python.
The run script for my infrastructure repo at one place I work for is almost 1,500 lines of shell scripting that's broken up into many small functions. It's not a big jumbled mess either, it's very organized and optimized to make running long commands faster and less error prone (even if they happen to be called in CI). These functions are mostly calling out to Terraform and other tools too, it's not 1,500 lines because of long winded cloud CLI tool API calls or anything like that.
[0]: https://nickjanetakis.com/blog/replacing-make-with-a-shell-s...
I'd say look at things like Oil(Is that project ever going to be the next big thing like it claims?).... but writing large scripts in ANY shell doesn't seem like the best plan.
What about an Ansible linter? Do those exist? I'm starting to suspect Ansible might be a better choice for a lot of what people do with bash, even if you are running om a desktop.
We should probably deprecate Bash, though. It doesn't offer enough over the standard for it to be worth it.
Arrays make a world of a difference. Deprecate sh, keep bash.
POSIX sh can pass arrays around just fine, and that's all it needs to do. Feature creep is the worst possible thing you could do to a shell scripting language.
Even if it were an implementation, how would you write a "POSIX sh" equivalent of these lines taken from a bash script? (My point isn't that it's impossible.)
mkdir -p /tmp/nodes/{a,b,c,d,e} ; NODES=( /tmp/nodes/{a,b,c,d,e} ) ; echo ${NODES[$((`date +%-j` % ${#NODES[@]}))]}
99% of my use case for Bash over POSIX sh is arrays, associative arrays, BASH_SOURCE, and PIPELINE (or whatever that array is that has positional pipe return status)
I second this: I myself have a problem here, which, I guess is somewhat related: I try to keep everything universal and each time I try to generalize what I'm trying to achieve through some sort of abstraction. As a consequence I keep adding functions and aliases to my (zsh/bash)rc to the point where they become absolutely unmanageable. At this very moment zshrc is 8000+ lines long. Sure, I have a cronjob which backs it up occasionally but it's more of a maintenance/readability hell.
My .xonshrc just has a few visual tweaks. I have set up exactly zero cron jobs aside from maybe whatever mint's backup stuff uses for a timer for the snapshots. I don't have any custom bash scripts that I use, other than some build scripts and things like that, that live in project repos.
I call it "decustomizing" as a counterpoint to minimalism. I don't try to simplify my life, I try to remove customization and unusual tech, even if that means using a 100MB app instead of a 1kb script I'd have to maintain myself.
I've just... had about enough of small time garage tinkering level projects at this point, and have really started to appreciate monolithic opinionated systems. Stuff that lets you do anything, in exactly one way.
I just threw out a few hundred lines of Python in exchange for ten lines of bash script + cron job to automate nightly app updates. Way easier to get working, way less painful to debug.
I grant, however, that it's not particularly large. What is?
Many simple file and data munging tasks can be done on cli 100x faster with 10x less code than Python, using rudimentary bash-fu.
I already see a wave of Python undoing in the data world. Python becoming the cobol of this field.
rudimentary bash-fu
I wish there were some guides on how to get past rudimentary bash-fu. My bash text munging is still pretty rudimentary. I'd love to get to journeyman text smash status.
https://github.com/sstadick/hck
So what you need is a good language that lets you concisely express ways of calling out to other programs. Not necessarily a language that's backward-compatible with whatever seemed like a good idea for a CLI in 1979.
All that in... (goes off to wc -l the script) ...twenty lines of bash, a third of which are comments.
> language that lets you concisely express ways of calling out to other programs
Seems like bash meets those requirements to me, dawg. /shrug
Still wish someone would give me an idea of what "large" means in bash scripts.
- concise (another operator instead of '|' is fine, but no extra parentheses, quotes etc.)
- performant (at least as fast as Bash)
- standalone (extensions discouraged)
Sorry even I can't say that with a straight face XD
That said, we do have a lot of helper scripts that we use in our day-to-day (local development). They're not "large bash scripts", but they're small enough that we don't really need a proper programming language. Some degree of code quality with style guides and shellcheck does help.
I agree with not making another docstring format. Accordingly, I updated the readme to explicitly also allow the linter to support the docstring format as provided by another style; that of shdoc.
In essence, the user should be able to configure the linter to adhere to Google Shell Style Guide or the one used by shdoc (or another, or some non-conflicting combination of options). To start somewhere, I propose Google Shell Style guide.
A procedure in bash can have way more complex interface than simple function call. See http://vlisivka.github.io/bash-modules/arguments.html for example.
The huge weakness of bash is the data mangling that is more complicated than a relatively simple per-row transformation. Splitting, joining, sorting, min/max of data structures like arrays, lists and trees are trivial in python, not so in bash. If it's not trivially seddable, and awk doesn't help either because you have to rearrange tree like structures, you are about to experience a massive pain, because it's out of scope of even expertly used associative arrays and you will find yourself reinventing the wheel hard using pretty basic tools.
TLDR; As soon as you need to use something more complex than a for loop, switch to Python.
Warn me when I'm using -h instead of --help. When writing persisted scripts it's often more descriptive and self documenting to use long form flags[0] when a tool gives you both options.
Warn me when I mix using "-p 8000:8000" and "-p=8000:8000". I know it's technically not possible to give a definitive answer here because supporting spaces vs equals comes down to the CLI tool itself but you can pick up when you mix the 2 styles and leave it up to the script author to pick a style and stick with it when possible.
I do a lot of shell scripting in my day to day (ops, etc.) and it's always a manual process to give feedback on code reviews when others contribute patches or introduce new scripts.
[0]: https://nickjanetakis.com/blog/when-to-use-long-word-or-shor...
See also where it says: > For preference, don’t use [[ … ]] at all for numeric comparisons, use (( … )) instead.
Compare/contrast Javascript, another language we're "stuck with": while you can still directly write it, many people don't, instead using transpilers to target Javascript as an "object code" from some other, stricter language (e.g. TypeScript, ClojureScript, arbitrary native languages via Emscripten / WASM compiler targets, etc.) Via these languages/compilers, everybody who wants compile-time strictness can have it. And you can even get runtime strictness that Javascript itself doesn't have, as the checked semantics of these languages will often be "lowered" into the resulting Javascript by generating explicit assertions (usually this is on by default, unless you ask for an optimized build.) But the output is still just "Javascript", that any browser can run.
Why hasn't a similar thing happened for POSIX-standard Bourne shell? Why are we seemingly "satisfied" with writing + maintaining 5000-line install.sh scripts in our codebases, with a bunch of extra mental overhead / tooling required to keep the code sane and clean; when we could just be writing in a sane and clean language to begin with?
Is it just that nobody's bothered to create such a language/compiler? Do I need to be the one to step up, here?
This is why a large fraction of the shell scripts that exist in the world still hold to Bourne shell syntax, rather than using any of the syntax extensions from its descendant shells: Bourne shell (or at least, something at /bin/sh that interprets Bourne-shell syntax) is part of the POSIX standard. So you can expect any POSIX system — no matter how weird — to be able to run (Bourne) shell scripts. You can run them on Alpine. You can run them on Busybox. You can run them on your NAS. You can run them on your router. You can run them in your initramfs, on your Kubernetes nodes, on your Mosix cluster, on your smart TV, on your watch, whatever. They run on Windows; they run on macOS; they (obviously) run on Linux and BSD. New Bourne shell scripts written today, run on 40-year-old operating systems nobody even remembers.
(If you think about it, the whole way that GNU autotools does its job, is by relying on the portability of POSIX-compliant Bourne shell syntax — i.e. the ./configure script — to bootstrap out enough of an understanding of whatever system it lands on, to find the tools required to then probe the system for the availability + capabilities of its compilation toolchain.)
For a small example of the type of script I'm talking about — see e.g. the script you download+run when you run the command-line on https://rustup.rs: https://github.com/hsivonen/rustup.rs/blob/master/rustup-ini....
For a much better example (that I sadly can't link to), see the install.sh for VMWare Workstation for Linux.
There's absolutely no benefit that these scripts get from being developed directly in POSIX-compiliant Bourne shell syntax, rather than being developed in something that compiles to said syntax; any more than programs for your PC would benefit from being developed directly in ASM, rather than in something that compiles to it.
My go-to is either Python or Ruby when shell scripts start to get unwieldy - lately it's been Ruby but I'm mentioning Python because I have experience with the type-checking options for Python, and you mentioned that as a benefit. I'm sure someone will point out that type-checking exists for Ruby too (Sorbet, others?); I've just never tried it.
Not to comment on how frequent this is for other people, but my rare, obscure, and carefully constructed scenario is this one:
Bash is popular because it’s available (nearly) everywhere, is very portable, and has a massive “standard library.” (Technically, not a standard library, but you can basically guarantee that things like sed, curl, rsync etc. will be available or easy to get anywhere.) And its constructs are very well fitted to smaller scripts and automations.
You loose those benefits with a compilation step. Nearly all the scripts I interact with are just smaller automations put into a “bin” folder in the repo. These scripts are used for the build steps of other programs. So what would you write for automations related to building a compiled bash project? Probably bash lol
If you’re writing thousands of lines, you can just use any other language, right? Pretty much any other scripting language can execute commands in bash anyways. To my knowledge, stepping into a different language once you need more refined flow control and data structures is pretty normal. Afaik many languages can either produce output which can be run directly on the CLI (rust, C++, C), and others can be executed if you have the language runtime installed (JS via node, python, etc.)
What you're talking about are scripts you (or developers you work closely with) write to then use yourselves, in development workflows. These tend to be small and simple, and also places where the pain-points of shell-scripting (e.g. spaces screwing up path parsing) don't tend to come up, because developers have enough sense to not make their development environment actively hostile in these ways.
But this is not the only, or even the main, use-case of writing Bourne shell scripts. (If you have a development toolchain installed, you probably already have Perl and/or Python and/or Ruby — so why not script your development workflow in one of those? Or, if everyone is at least running a modern Linux or macOS+Homebrew, why not assume the existence of Bash or Zsh, and write for one of those, instead of Bourne shell?)
Instead, the main places where large, complex shell scripts are necessary, are in:
1. multi-platform software distribution;
2. systems-level glue code (think: initrd scripts; sysvinit back when that was a thing; etc) that must be runnable "at early boot" or in a "rescue environment" — i.e. one that can't guarantee that /usr is mounted, or that /lib is set up correctly to make non-static binaries work, etc. (Or in an "embedded environment" — in Busybox, /usr doesn't even exist!)
In the first case, the person writing the shell script is not the person running the shell script. Tons of different people run the shell script, on (sometimes drastically) different environments, and these are not developers, and their machines mostly aren't guaranteed to have anything like a development toolchain installed.
In the second case, despite targeting a single environment, said environment just happens to have a Bourne-shell compatible interpreter as its only scripting environment. (And probably even that was only included because the environment wanted to claim POSIX compatibility, never expecting people to want to script it rather than compiling native tools statically into the environment. Even though most sysadmins who might need to modify these environments, have no idea how to do that.) Mind you, some of these systems — e.g. UEFI — aren't POSIX-compatible at all, but rather require you to "script" them in something totally different, e.g. Forth.
> If you’re writing thousands of lines, you can just use any other language, right?
No, not if you can't make any assumptions about the target environment beyond "it's POSIX"; or if the target environment really doesn't offer anything not strictly required by POSIX. There is no POSIX-standard scripting environment other than Bourne shell. (And you can't just ship a binary, because you don't know the target architecture. Detecting the architecture you've landed on is probably part of what you're trying to do!)
At the end of the day, it's probably less painful to just bootstrap some saner interfaces if you really need them. The POSIX C API is just as standardized and exposes more than the shell interfaces.
I reiterate my example: the install.sh for the Linux edition of VMWare Workstation. Its job is to take an arbitrary machine running a desktop Linux OS (i.e. it does assume an X installation), and turn it into a hypervisor dom0.
To do this, it needs to compile its proprietary Linux kernel modules using DKMS. But 1. it can't assume that you have a compiler toolchain installed; and 2. it can't assume that you have the kernel header package installed for your kernel.
So this install script needs to detect your OS distro-family, and use that to determine how to forcibly install some packages, in order to then have the tools (and headers) available to compile things.
How would you suggest that this installation logic be implemented? Because right now it's "thousands of lines of extremely grody Bourne shell script" — and AFAICT, that's because that was the optimal choice given the problem domain.
I suppose you could build such an installer as a collection of static binaries, one for each architecture. (Static, because different distros name their libs differently, so dynamic linkage isn't portable.) You could then create a little install.sh that runs them.
And, if you wanted all this to be tucked into a single file so you could run it with the unsafe curl-to-shell hack, you could even base64-armor those binaries and drop them into some heredocs inside a bootstrap shell-script, such that you do get to have a single "install.sh", which just pops out copy of the 'correct' binary into existence and runs it.
But IMHO, by doing something like this, you're just Greenspunning a Bourne-shell object-code format; the same one a compiler could emit. But without the benefit of getting to directly use a linker to generate the result.
I vaguely remember quite liking bish when I saw it years ago https://github.com/tdenniston/bish but it looks like no commits in 6 years.
This shelljs thing looks more promising, but really tedious to use https://github.com/shelljs/shelljs - shell.rm('-rf', 'out/Release'); I'd rather suffer proper bash than have to do that sort of thing.
Nothing seems to have really caught on so far. Bash is easy to learn and hack on, and before you know it, that simple install.sh that started out moving a few files around is 5000 lines, unmaintainable, and critical to bootstrapping your software :)
And as the Guide itself, it is mostly OK, but there are a few things I really don't like. For example:
- Whatever doc format they use is not as nice to use as shdoc. Namely, you don't have to add the spaces to your comment to make it formatted properly
- The 'no tabs' thing is pretty bullshit - Bash is literally the perfect language to use tabs and their "Whatever you do, don’t use tabs" comment really kills me
- I don't like their recommendation of using `[[`. Only using `[[` for stuff like regex and glob matching makes it much easier to paste code between Bash and POSIX sh.
There are also a few things they forgot to mention, like the lastpipe` shopt option when talking about piping while loops. And maybe it would be nice to recommend using local like `local var=`. But I guess this guide is meant more for new people that don't know all the pitfalls and stuff
But I think I agree there is some need for a more extensible linter. I've been eyeing the Bash LSP, which is based off the Bash Tree Sitter parser, but that has quite a few bugs with parsing that I've been meaning to send PR's for.
I wonder if `shfmt` has gotten better since I used it. A few years ago, it was quite buggy with prepending extra whitespace whenever I formatted the file