Repository: tweag/topiary (Rust)

Issue:
Gate (at least) Bash support behind a feature flag
**Is your feature request related to a problem? Please describe.**
We have seen a number of times that building `tree-sitter-bash` fails as it references a (somehow) invalid ref on Savannah (GNU's git service, which hosts the Bash repository). The offending commit does appear to exist, so it's not clear why this occurs.

In the past, we've managed to resolve this by bumping the `tree-sitter-bash` version. However, IIRC, we have reached the point where this is no longer possible; newer versions use TS 0.21 (see #684). It's therefore possible that this problem could reoccur and there'd be no simple fix.

**Describe the solution you'd like**
Put Bash support behind a feature gate, which is disabled by default.

**Describe alternatives you've considered**
Remove Bash support completely, until we can move to a newer version.

**Additional context**
`topiary-queries` already uses feature flags; I believe we'd just have to remove Bash from the default features to achieve this.

We're singling out Bash because it is unstable, but perhaps there is also an argument to be had for gating _all_ experimental languages behind an off-by-default feature flag.


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ca18a2d..e0527624 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,7 @@ This name should be decided amongst the team before the release.
 
 ### Changed
 - [#704](https://github.com/tweag/topiary/pull/704) Refactors our postprocessing code to be more versatile.
+- [#711](https://github.com/tweag/topiary/pull/711) Feature gate all grammars, with supported and contributed languages built by default.
 
 ## v0.4.0 - Exquisite Elm - 2024-05-15
 
diff --git a/Cargo.toml b/Cargo.toml
index f0cf08ed..b8581b76 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -76,9 +76,9 @@ tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css.git",
 tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json.git", rev = "3b129203f4b72d532f58e72c5310c0a7db3b8e6d" }
 tree-sitter-nickel = { git = "https://github.com/nickel-lang/tree-sitter-nickel", rev = "43433d8477b24cd13acaac20a66deda49b7e2547" }
 tree-sitter-ocaml = { git = "https://github.com/tree-sitter/tree-sitter-ocaml.git", rev = "9965d208337d88bbf1a38ad0b0fe49e5f5ec9677" }
-tree-sitter-ocamllex = { git = "https://github.com/314eter/tree-sitter-ocamllex.git", rev = "4b9898ccbf198602bb0dec9cd67cc1d2c0a4fad2"}
+tree-sitter-ocamllex = { git = "https://github.com/314eter/tree-sitter-ocamllex.git", rev = "4b9898ccbf198602bb0dec9cd67cc1d2c0a4fad2" }
 tree-sitter-query = { git = "https://github.com/nvim-treesitter/tree-sitter-query", rev = "a0ccc351e5e868ec1f8135e97aa3b53c663cf2df" }
-tree-sitter-rust = { git = "https://github.com/tree-sitter/tree-sitter-rust.git", rev = "e0e8b6de6e4aa354749c794f5f36a906dcccda74"}
+tree-sitter-rust = { git = "https://github.com/tree-sitter/tree-sitter-rust.git", rev = "e0e8b6de6e4aa354749c794f5f36a906dcccda74" }
 tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml.git", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" }
 unescape = "0.1"
 wasm-bindgen = "=0.2.91"
diff --git a/README.md b/README.md
index 3157467d..21015c87 100644
--- a/README.md
+++ b/README.md
@@ -86,25 +86,35 @@ experimental.
 #### Supported
 
 These formatting styles cover their target language and fulfil Topiary's
-stated design goals. They are exposed, in Topiary, through a command
-line flag.
+stated design goals. They are exposed, in Topiary, through the
+`--language` command line flag, or language detection (based on file
+extension).
 
+* [JSON]
+* [Nickel]
 * [OCaml] (both implementations and interfaces)
 * [OCamllex]
-* [Nickel]
-* [JSON]
 * [TOML]
+* [Tree Sitter Queries][tree-sitter-query]
+
+#### Contributed
+
+These languages' formatting styles have been generously provided by
+external contributors. They are built in, by default, so are exposed in
+the same way as supported languages.
+
+* [CSS] by @lavigneer
 
 #### Experimental
 
 These languages' formatting styles are subject to change and/or not yet
-considered production-ready. They can be accessed in Topiary by
-specifying the path to their query files.
+considered production-ready. They are _not_ built by default and are
+gated behind a feature flag (either `experimental`, for all of them, or
+by their individual name). Once included, they can be accessed in
+Topiary in the usual way.
 
-* [Rust]
 * [Bash]
-* [Tree Sitter Queries][tree-sitter-query]
-* [CSS]
+* [Rust]
 
 ## Getting Started
 
diff --git a/examples/client-app/Cargo.toml b/examples/client-app/Cargo.toml
index 15d7923a..84ab7def 100644
--- a/examples/client-app/Cargo.toml
+++ b/examples/client-app/Cargo.toml
@@ -6,5 +6,5 @@ edition = "2021"
 [dependencies]
 tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
 topiary-core.path = "../../topiary-core"
-topiary-config.path = "../../topiary-config"
+topiary-config = { path = "../../topiary-config", features = ["json"] }
 topiary-queries = { path = "../../topiary-queries", features = ["json"] }
diff --git a/topiary-cli/Cargo.toml b/topiary-cli/Cargo.toml
index 36e5a1ad..9449eea9 100644
--- a/topiary-cli/Cargo.toml
+++ b/topiary-cli/Cargo.toml
@@ -43,16 +43,50 @@ topiary-config = { path = "../topiary-config" }
 topiary-queries = { path = "../topiary-queries" }
 topiary-tree-sitter-facade = { workspace = true }
 
-tree-sitter-json = { workspace = true }
-tree-sitter-css = { workspace = true }
-tree-sitter-rust = { workspace = true }
-tree-sitter-toml = { workspace = true }
-tree-sitter-bash = { workspace = true }
-tree-sitter-nickel = { workspace = true }
-tree-sitter-query = { workspace = true }
-tree-sitter-ocaml = { workspace = true }
-tree-sitter-ocamllex = { workspace = true }
+tree-sitter-json = { workspace = true, optional = true }
+tree-sitter-css = { workspace = true, optional = true }
+tree-sitter-rust = { workspace = true, optional = true }
+tree-sitter-toml = { workspace = true, optional = true }
+tree-sitter-bash = { workspace = true, optional = true }
+tree-sitter-nickel = { workspace = true, optional = true }
+tree-sitter-query = { workspace = true, optional = true }
+tree-sitter-ocaml = { workspace = true, optional = true }
+tree-sitter-ocamllex = { workspace = true, optional = true }
 
 [dev-dependencies]
 assert_cmd = { workspace = true }
 predicates = { workspace = true }
+
+[features]
+default = [
+  "contributed",
+  "json",
+  "nickel",
+  "ocaml",
+  "ocaml_interface",
+  "ocamllex",
+  "toml",
+  "tree_sitter_query"
+]
+
+# Included by default
+contributed = [
+  "css"
+]
+
+# Excluded by default
+experimental = [
+  "bash",
+  "rust",
+]
+
+bash = ["dep:tree-sitter-bash", "topiary-config/bash", "topiary-queries/bash"]
+css = ["dep:tree-sitter-css", "topiary-config/css", "topiary-queries/css"]
+json = ["dep:tree-sitter-json", "topiary-config/json", "topiary-queries/json"]
+nickel = ["dep:tree-sitter-nickel", "topiary-config/nickel", "topiary-queries/nickel"]
+ocaml = ["dep:tree-sitter-ocaml", "topiary-config/ocaml", "topiary-queries/ocaml"]
+ocaml_interface = ["dep:tree-sitter-ocaml", "topiary-config/ocaml_interface", "topiary-queries/ocaml_interface"]
+ocamllex = ["dep:tree-sitter-ocamllex", "topiary-config/ocamllex", "topiary-queries/ocamllex"]
+rust = ["dep:tree-sitter-rust", "topiary-config/rust", "topiary-queries/rust"]
+toml = ["dep:tree-sitter-toml", "topiary-config/toml", "topiary-queries/toml"]
+tree_sitter_query = ["dep:tree-sitter-query", "topiary-config/tree_sitter_query", "topiary-queries/tree_sitter_query"]
diff --git a/topiary-cli/src/io.rs b/topiary-cli/src/io.rs
index 1de911d7..a10762a4 100644
--- a/topiary-cli/src/io.rs
+++ b/topiary-cli/src/io.rs
@@ -323,16 +323,36 @@ where
     T: AsRef<str> + fmt::Display,
 {
     match name.as_ref() {
+        #[cfg(feature = "bash")]
         "bash" => Ok(topiary_queries::bash().into()),
+
+        #[cfg(feature = "css")]
         "css" => Ok(topiary_queries::css().into()),
+
+        #[cfg(feature = "json")]
         "json" => Ok(topiary_queries::json().into()),
+
+        #[cfg(feature = "nickel")]
         "nickel" => Ok(topiary_queries::nickel().into()),
+
+        #[cfg(feature = "ocaml")]
         "ocaml" => Ok(topiary_queries::ocaml().into()),
+
+        #[cfg(feature = "ocaml_interface")]
         "ocaml_interface" => Ok(topiary_queries::ocaml_interface().into()),
+
+        #[cfg(feature = "ocamllex")]
         "ocamllex" => Ok(topiary_queries::ocamllex().into()),
+
+        #[cfg(feature = "rust")]
         "rust" => Ok(topiary_queries::rust().into()),
+
+        #[cfg(feature = "toml")]
         "toml" => Ok(topiary_queries::toml().into()),
+
+        #[cfg(feature = "tree_sitter_query")]
         "tree_sitter_query" => Ok(topiary_queries::tree_sitter_query().into()),
+
         name => Err(TopiaryError::Bin(
             format!("The specified language is unsupported: {}", name),
             Some(CLIError::UnsupportedLanguage(name.to_string())),
diff --git a/topiary-config/Cargo.toml b/topiary-config/Cargo.toml
index 31558f65..73504a70 100644
--- a/topiary-config/Cargo.toml
+++ b/topiary-config/Cargo.toml
@@ -22,15 +22,42 @@ toml.workspace = true
 
 topiary-tree-sitter-facade.workspace = true
 topiary-web-tree-sitter-sys.workspace = true
-tree-sitter-json.workspace = true
-tree-sitter-rust.workspace = true
-tree-sitter-toml.workspace = true
-tree-sitter-bash.workspace = true
-tree-sitter-css.workspace = true
-tree-sitter-nickel.workspace = true
-tree-sitter-query.workspace = true
-tree-sitter-ocaml.workspace = true
-tree-sitter-ocamllex.workspace = true
+tree-sitter-json = { workspace = true, optional = true }
+tree-sitter-rust = { workspace = true, optional = true }
+tree-sitter-toml = { workspace = true, optional = true }
+tree-sitter-bash = { workspace = true, optional = true }
+tree-sitter-css = { workspace = true, optional = true }
+tree-sitter-nickel = { workspace = true, optional = true }
+tree-sitter-query = { workspace = true, optional = true }
+tree-sitter-ocaml = { workspace = true, optional = true }
+tree-sitter-ocamllex = { workspace = true, optional = true }
 
 [target.'cfg(not(target_family = "wasm"))'.dependencies]
 clap = { workspace = true, features = ["derive"] }
+
+[features]
+bash = ["dep:tree-sitter-bash"]
+css = ["dep:tree-sitter-css"]
+json = ["dep:tree-sitter-json"]
+nickel = ["dep:tree-sitter-nickel"]
+ocaml = ["dep:tree-sitter-ocaml"]
+ocaml_interface = ["dep:tree-sitter-ocaml"]
+ocamllex = ["dep:tree-sitter-ocamllex"]
+rust = ["dep:tree-sitter-rust"]
+toml = ["dep:tree-sitter-toml"]
+tree_sitter_query = ["dep:tree-sitter-query"]
+
+# This a convenience for the sake of downstream applications which don't
+# wish to cherry-pick grammars (e.g., the playground)
+all = [
+  "bash",
+  "css",
+  "json",
+  "nickel",
+  "ocaml",
+  "ocaml_interface",
+  "ocamllex",
+  "rust",
+  "toml",
+  "tree_sitter_query"
+]
diff --git a/topiary-config/src/serde.rs b/topiary-config/src/serde.rs
index a815abd1..186b9eb3 100644
--- a/topiary-config/src/serde.rs
+++ b/topiary-config/src/serde.rs
@@ -35,15 +35,36 @@ impl Language {
     #[cfg(not(wasm))]
     pub fn find_query_file(&self) -> TopiaryConfigResult<PathBuf> {
         let basename = PathBuf::from(match self.name.as_str() {
+            #[cfg(feature = "bash")]
             "bash" => "bash",
+
+            #[cfg(feature = "css")]
             "css" => "css",
+
+            #[cfg(feature = "json")]
             "json" => "json",
+
+            #[cfg(feature = "nickel")]
             "nickel" => "nickel",
-            "ocaml" | "ocaml_interface" => "ocaml",
+
+            #[cfg(feature = "ocaml")]
+            "ocaml" => "ocaml",
+
+            #[cfg(feature = "ocaml_interface")]
+            "ocaml_interface" => "ocaml",
+
+            #[cfg(feature = "ocamllex")]
             "ocamllex" => "ocamllex",
+
+            #[cfg(feature = "rust")]
             "rust" => "rust",
+
+            #[cfg(feature = "toml")]
             "toml" => "toml",
+
+            #[cfg(feature = "tree_sitter_query")]
             "tree_sitter_query" => "tree-sitter-query",
+
             name => return Err(TopiaryConfigError::UnknownLanguage(name.to_string())),
         })
         .with_extension("scm");
@@ -67,16 +88,36 @@ impl Language {
     #[cfg(not(target_arch = "wasm32"))]
     pub fn grammar(&self) -> TopiaryConfigResult<topiary_tree_sitter_facade::Language> {
         Ok(match self.name.as_str() {
+            #[cfg(feature = "bash")]
             "bash" => tree_sitter_bash::language(),
+
+            #[cfg(feature = "css")]
             "css" => tree_sitter_css::language(),
+
+            #[cfg(feature = "json")]
             "json" => tree_sitter_json::language(),
+
+            #[cfg(feature = "nickel")]
             "nickel" => tree_sitter_nickel::language(),
+
+            #[cfg(feature = "ocaml")]
             "ocaml" => tree_sitter_ocaml::language_ocaml(),
+
+            #[cfg(feature = "ocaml_interface")]
             "ocaml_interface" => tree_sitter_ocaml::language_ocaml_interface(),
+
+            #[cfg(feature = "ocamllex")]
             "ocamllex" => tree_sitter_ocamllex::language(),
+
+            #[cfg(feature = "rust")]
             "rust" => tree_sitter_rust::language(),
+
+            #[cfg(feature = "toml")]
             "toml" => tree_sitter_toml::language(),
+
+            #[cfg(feature = "tree_sitter_query")]
             "tree_sitter_query" => tree_sitter_query::language(),
+
             name => return Err(TopiaryConfigError::UnknownLanguage(name.to_string())),
         }
         .into())
@@ -85,16 +126,36 @@ impl Language {
     #[cfg(target_arch = "wasm32")]
     pub async fn grammar(&self) -> TopiaryConfigResult<topiary_tree_sitter_facade::Language> {
         let language_name = match self.name.as_str() {
+            #[cfg(feature = "bash")]
             "bash" => "bash",
+
+            #[cfg(feature = "css")]
             "css" => "css",
+
+            #[cfg(feature = "json")]
             "json" => "json",
+
+            #[cfg(feature = "nickel")]
             "nickel" => "nickel",
+
+            #[cfg(feature = "ocaml")]
             "ocaml" => "ocaml",
+
+            #[cfg(feature = "ocaml_interface")]
             "ocaml_interface" => "ocaml_interface",
+
+            #[cfg(feature = "ocamllex")]
             "ocamllex" => "ocamllex",
+
+            #[cfg(feature = "rust")]
             "rust" => "rust",
+
+            #[cfg(feature = "toml")]
             "toml" => "toml",
+
+            #[cfg(feature = "tree_sitter_query")]
             "tree_sitter_query" => "query",
+
             name => return Err(TopiaryConfigError::UnknownLanguage(name.to_string())),
         };
 
diff --git a/topiary-playground/Cargo.toml b/topiary-playground/Cargo.toml
index 888250fe..5744c4a2 100644
--- a/topiary-playground/Cargo.toml
+++ b/topiary-playground/Cargo.toml
@@ -24,7 +24,7 @@ crate-type = ["cdylib"]
 
 [dependencies]
 topiary-core = { path = "../topiary-core" }
-topiary-config = { path = "../topiary-config" }
+topiary-config = { path = "../topiary-config", features = ["all"] }
 topiary-tree-sitter-facade.workspace = true
 wasm-bindgen.workspace = true
 wasm-bindgen-futures.workspace = true
diff --git a/topiary-queries/Cargo.toml b/topiary-queries/Cargo.toml
index d2acc7d3..45969a10 100644
--- a/topiary-queries/Cargo.toml
+++ b/topiary-queries/Cargo.toml
@@ -15,18 +15,6 @@ description = """
 """
 
 [features]
-default = [
-  "bash",
-  "css",
-  "json",
-  "nickel",
-  "ocaml",
-  "ocaml_interface",
-  "ocamllex",
-  "rust",
-  "toml",
-  "tree_sitter_query",
-]
 bash = []
 css = []
 json = []


Test patch:
diff --git a/topiary-cli/tests/cli-tester.rs b/topiary-cli/tests/cli-tester.rs
index 26a2b1d1..5feb1735 100644
--- a/topiary-cli/tests/cli-tester.rs
+++ b/topiary-cli/tests/cli-tester.rs
@@ -45,6 +45,7 @@ impl State {
 }
 
 #[test]
+#[cfg(feature = "json")]
 fn test_fmt_stdin() {
     let mut topiary = Command::cargo_bin("topiary").unwrap();
 
@@ -60,6 +61,7 @@ fn test_fmt_stdin() {
 }
 
 #[test]
+#[cfg(feature = "json")]
 fn test_fmt_stdin_query() {
     let mut topiary = Command::cargo_bin("topiary").unwrap();
 
@@ -77,6 +79,7 @@ fn test_fmt_stdin_query() {
 }
 
 #[test]
+#[cfg(all(feature = "json", feature = "toml"))]
 fn test_fmt_files() {
     let json = State::new(JSON_INPUT, "json");
     let toml = State::new(TOML_INPUT, "toml");
@@ -96,6 +99,7 @@ fn test_fmt_files() {
 }
 
 #[test]
+#[cfg(feature = "json")]
 fn test_fmt_dir() {
     let json = State::new(JSON_INPUT, "json");
 
@@ -112,6 +116,7 @@ fn test_fmt_dir() {
 }
 
 #[test]
+#[cfg(feature = "json")]
 fn test_fmt_invalid() {
     let mut topiary = Command::cargo_bin("topiary").unwrap();
 
@@ -136,6 +141,7 @@ fn test_fmt_invalid() {
 }
 
 #[test]
+#[cfg(feature = "json")]
 fn test_vis() {
     let mut topiary = Command::cargo_bin("topiary").unwrap();
 
@@ -154,6 +160,7 @@ fn test_vis() {
 }
 
 #[test]
+#[cfg(feature = "json")]
 fn test_vis_invalid() {
     let mut topiary = Command::cargo_bin("topiary").unwrap();
 
diff --git a/topiary-cli/tests/sample-tester.rs b/topiary-cli/tests/sample-tester.rs
index 78f507e5..18fce53b 100644
--- a/topiary-cli/tests/sample-tester.rs
+++ b/topiary-cli/tests/sample-tester.rs
@@ -1,48 +1,79 @@
 use assert_cmd::Command;
 use std::fs;
-use std::path::Path;
+use std::path::PathBuf;
 use topiary_core::test_utils::pretty_assert_eq;
 
 use tempfile::TempDir;
 
+fn io_test(file: &str) -> () {
+    let input = PathBuf::from(format!("tests/samples/input/{file}"));
+    let expected = PathBuf::from(format!("tests/samples/expected/{file}"));
+
+    // Make sure our test makes sense
+    assert!(input.exists() && expected.exists());
+
+    // Load the known good formatted file
+    let expected_output = fs::read_to_string(&expected).unwrap();
+
+    // Stage the input to a temporary directory
+    let tmp = TempDir::new().unwrap();
+    let staged = tmp.path().join(file);
+    fs::copy(input, &staged).unwrap();
+
+    // Run Topiary against the staged input file
+    let mut topiary = Command::cargo_bin("topiary").unwrap();
+    topiary
+        .env("TOPIARY_LANGUAGE_DIR", "../topiary-queries/queries/")
+        .arg("fmt")
+        .arg(&staged)
+        .assert()
+        .success();
+
+    // Read the file after formatting
+    let formatted = fs::read_to_string(&staged).unwrap();
+
+    // Assert the formatted file is as expected
+    pretty_assert_eq(&expected_output, &formatted);
+}
+
 #[test]
 fn input_output_tester() {
-    let input_dir = fs::read_dir("tests/samples/input").unwrap();
-    let expected_dir = Path::new("tests/samples/expected");
+    // TODO There's probably a better way than this...
 
-    for file in input_dir {
-        let file = file.unwrap();
+    #[cfg(feature = "bash")]
+    io_test("bash.sh");
 
-        // Load the known good formated files
-        let expected_path = expected_dir.join(file.file_name());
-        let expected = fs::read_to_string(expected_path).unwrap();
+    #[cfg(feature = "css")]
+    io_test("css.css");
 
-        let tmp_dir = TempDir::new().unwrap();
+    #[cfg(feature = "json")]
+    io_test("json.json");
 
-        // Copy the file to a temp dir
-        let mut input_file = tmp_dir.path().to_path_buf();
-        input_file.push(file.path().file_name().unwrap());
-        fs::copy(file.path(), &input_file).unwrap();
+    #[cfg(feature = "nickel")]
+    io_test("nickel.ncl");
 
-        // Run topiary on the input file in the temp dir
-        let mut topiary = Command::cargo_bin("topiary").unwrap();
-        topiary
-            .env("TOPIARY_LANGUAGE_DIR", "../topiary-queries/queries/")
-            .arg("fmt")
-            .arg(&input_file)
-            .assert()
-            .success();
+    #[cfg(feature = "ocaml")]
+    io_test("ocaml.ml");
 
-        // Read the file after formatting
-        let formatted = fs::read_to_string(input_file).unwrap();
+    #[cfg(feature = "ocaml_interface")]
+    io_test("ocaml-interface.mli");
 
-        // Assert the formatted file is as expected
-        pretty_assert_eq(&expected, &formatted);
-    }
+    #[cfg(feature = "ocamllex")]
+    io_test("ocamllex.mll");
+
+    #[cfg(feature = "rust")]
+    io_test("rust.rs");
+
+    #[cfg(feature = "toml")]
+    io_test("toml.toml");
+
+    #[cfg(feature = "tree_sitter_query")]
+    io_test("tree-sitter-query.scm");
 }
 
 // Test that our query files are properly formatted
 #[test]
+#[cfg(feature = "tree_sitter_query")]
 fn formatted_query_tester() {
     let language_dir = fs::read_dir("../topiary-queries/queries").unwrap();