Skip to content

Implement stdin support#471

Merged
DavisVaughan merged 8 commits intomainfrom
feature/stdin
Mar 13, 2026
Merged

Implement stdin support#471
DavisVaughan merged 8 commits intomainfrom
feature/stdin

Conversation

@DavisVaughan
Copy link
Copy Markdown
Collaborator

@DavisVaughan DavisVaughan commented Mar 4, 2026

Closes #202

New --stdin-file-path <path> option to enable stdin support in the Air CLI.

This is extremely similar to what most of the CLI formatting ecosystem does

biome --stdin-file-path <path>

prettier --stdin-filepath <path>

black --stdin-filename <path>

ruff format - --stdin-filename <path>

This option is a bit weird, and different formatters handle it slightly differently.

Three reasons to have --stdin-file-path

  • It is the trigger to recognize that you're in "stdin mode". If it is supplied, we read from stdin and also error if any files or directories are provided the "normal" way.

  • It determines where to start the search for air.toml files. This is extremely important!

    • For preexisting files, if your IDE formats via stdin then we need to know where the stdin comes from. i.e. if stdin came from project/file.R and you have project/air.toml, we need --stdin-file-path project/file.R so we can find your config file

    • For new files, where some tool has created R code but you haven't written it to disk yet, this is equally as important. If you want to write to project/doesnt-exist-yet.R and you have project.air.toml, we need --stdin-file-path project/doesnt-exist-yet.R to find your config file. Note that this means that the file provided doesn't even have to exist yet. And that's considered a feature!

  • It determines the file extension to associate with stdin. For us, that is currently always .R and we don't really do anything with this. But for prettier, biome, and ruff, which can all format multiple file types, the --stdin-file-path tells them which formatter to use under the hood. Again, even if the file doesn't exist, the extension provides valuable info. I like that this leaves the door open for .Rmd or .qmd support if we want to add it in the future.

Other miscellaneous details

  • If a relative path is provided to --stdin-file-path, it is resolved from the current working directory. So basically air format file.R and air format --stdin-file-path file.R resolve the same way. I think this is right, but I would hope that any IDE that uses this feature would supply an absolute path as --stdin-file-path to avoid any funny business that could result from them launching air in a weird directory or something. Providing an absolute path removes any chance to miss an air.toml.

  • I think our default exclude behavior for air format cpp11.R, where cpp11.R is a default-exclude, is not quite right. I think that both here and in air format --stdin-file-path cpp11.R, we should refuse to format these by default, and maybe provide some way to opt back in when you really want it. This would better support both pre-commit and RStudio, where they both blindly provide air whatever file the user saved or edited in, and they have no way of knowing what the excludes are. I think it is more important for that to refuse to format cpp11.R than it is for a user to explicitly run air format cpp11.R at the command line and get that file formatted. I'd argue we probably should not even provide an opt in flag to allow this at all. air format <file>.R and air format --stdin-file-path <file>.R should respect exclude and default-exclude #472

  • Similarly, I think our default include behavior for air format file.qmd, where file.qmd is not in default-include, is not quite right. We've had a lot of people get confused that air format file.qmd fake works. If you don't get a parse error by chance, then air will just completely break your qmd. I'd again argue that we should refuse to format this, maybe with a warning. Alternatively, if we feel like this goes a step too far, then I'd be ok with black listing .qmd and .Rmd, while still allowing everything else, like air format NAMESPACE, since that's technically an R file and isn't "excluded". air format <file>.qmd and air format --stdin-file-path <file>.qmd should maybe respect default-include #473

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was extracted from format.rs but nothing really changed, so you can skip review of this file

Comment on lines +110 to +114
// Blocks until EOF is received!
io::stdin()
.lock()
.read_to_string(&mut old)
.map_err(FormatStdinError::Read)?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Air will "hang" if you just do this at the command line without providing it any stdin

air format --stdin-file-path foo.R

The process is waiting for you to write to stdin and will hang until you get an EOF.

I think this is all correct, it just caught me by surprise the first time

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep that's standard behaviour

Comment on lines +488 to +535
/// TODO!: This test should FAIL once we change Air's defaults regarding directly supplied
/// files like `air format standalone-*.R` or `air format --stdin-file-path
/// standalone-*.R` and how they interact with `exclude`, `default-exclude`, and
/// `default-include`.
///
/// To better support pre-commit and RStudio, which will blindly provide whatever the user
/// has touched / saved to air as a file to format, we SHOULD respect the `exclude` rules
/// here by default and refuse to format this at the command line. If a user really wants
/// to bypass the exclude rules then they can do something like
/// `--ignore-exclude-for-directly-supplied-file` (or maybe we wouldn't allow this at
/// all?). This is honestly more in line with the LSP. If you do `Format Document` in
/// an excluded file, then we still refuse to format it!
///
/// Also, this would help with Quarto/Rmarkdown where people are trying to do `air format
/// test.Rmd` and they either get an obscure parse error or it actually fake works due to
/// chance. At the very least we'd now silently refuse to format this file because it
/// isn't in our `default-includes`, even though they provided it directly at the command
/// line. Or we could report a warning about this rather than being silent, and still
/// refuse to format.
#[test]
fn test_stdin_refuses_to_format_default_excludes() -> anyhow::Result<()> {
let directory = TempDir::new()?;
let directory = directory.path();

let cpp11_path = "cpp11.R";
let cpp11_contents = "1+1";
std::fs::write(directory.join(cpp11_path), cpp11_contents)?;

// `cpp11.R` is a `default-exclude` so it SHOULD refuse to format this
// and just reemit the existing `1+1` asis
insta::assert_snapshot!(
Command::new(binary_path())
.current_dir(directory)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg("format")
.arg("--stdin-file-path")
.arg(cpp11_path)
.run_with_stdin(cpp11_contents.to_string())
);

Ok(())
}

// TODO!: This should also refuse to format. See above.
#[test]
fn test_stdin_refuses_to_format_user_excludes() -> anyhow::Result<()> {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two tests which I think should probably fail once we change the default exclude behavior, this would better support pre-commit and rstudio

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These now pass in #476


/// Representation of a formatted file
#[derive(Debug)]
pub enum FormattedFile {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The little format_file() helper from here moved into format/paths.rs directly, and that allowed me to flatten a lot of unneeded abstraction and remove most of these intermediate enums

Comment thread crates/air/tests/integration/format.rs
@DavisVaughan DavisVaughan requested a review from lionel- March 6, 2026 19:50
Comment on lines +110 to +114
// Blocks until EOF is received!
io::stdin()
.lock()
.read_to_string(&mut old)
.map_err(FormatStdinError::Read)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep that's standard behaviour

}
fn check_argument_consistency(command: &FormatCommand) -> Option<ExitStatus> {
if command.stdin_file_path.is_some() && !command.paths.is_empty() {
tracing::error!(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still feels weird to me that we communicate user-facing messages on stderr via the log infrastructure, even if it also makes sense 🙃

@lionel-
Copy link
Copy Markdown
Collaborator

lionel- commented Mar 12, 2026

This option is a bit weird, and different formatters handle it slightly differently.

It seems nice to follow the same general API as other formatters, to facilitate integrations with external tools.

@DavisVaughan DavisVaughan merged commit a69e9d4 into main Mar 13, 2026
6 checks passed
@DavisVaughan DavisVaughan deleted the feature/stdin branch March 13, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for stdin in air format

2 participants