Skip to content

Commit

Permalink
Merge pull request #506 from tweag/505-user-configuration-file
Browse files Browse the repository at this point in the history
Read user defined configuration file from $ROOT/.topiary/languages.toml
  • Loading branch information
Erin van der Veen authored Jun 15, 2023
2 parents 442b441 + 1c8b4ee commit e5d2db2
Show file tree
Hide file tree
Showing 14 changed files with 238 additions and 20 deletions.
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ assert_cmd = "2.0"
cfg-if = "1.0.0"
clap = "4.3"
criterion = "0.4"
directories = "5.0"
env_logger = "0.10"
futures = "0.3.28"
itertools = "0.10"
Expand All @@ -28,6 +29,7 @@ pretty_assertions = "1.3"
prettydiff = "0.6.4"
regex = "1.8.2"
serde = "1.0.163"
serde-toml-merge = "0.3"
serde_json = "1.0"
tempfile = "3.5.0"
test-log = "0.2.11"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ Options:
-i, --in-place Format the input file in place
-v, --visualise[=<OUTPUT_FORMAT>] Visualise the syntax tree, rather than format [possible values: json, dot]
-s, --skip-idempotence Do not check that formatting twice gives the same output
--output-configuration Output the full configuration to stderr before continuing
-h, --help Print help
-V, --version Print version
```
Expand Down
2 changes: 2 additions & 0 deletions topiary-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ path = "src/main.rs"
# Eventually we will want to dynamically load them, like Helix does.
clap = { workspace = true, features = ["derive"] }
env_logger = { workspace = true }
directories = { workspace = true }
log = { workspace = true }
serde-toml-merge = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
toml = { workspace = true }
Expand Down
123 changes: 123 additions & 0 deletions topiary-cli/src/configuration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use directories::ProjectDirs;
use std::{env::current_dir, path::PathBuf};
use topiary::{default_configuration_toml, Configuration};

use crate::error::{CLIResult, TopiaryError};

pub fn parse_configuration() -> CLIResult<Configuration> {
user_configuration_toml()?
.try_into()
.map_err(TopiaryError::from)
}

/// User configured languages.toml file, merged with the default config.
fn user_configuration_toml() -> CLIResult<toml::Value> {
let config = [find_configuration_dir(), find_workspace().join(".topiary")]
.into_iter()
.map(|path| path.join("languages.toml"))
.filter_map(|file| {
std::fs::read_to_string(file)
.map(|config| toml::from_str(&config))
.ok()
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.fold(default_configuration_toml(), |a, b| {
merge_toml_values(a, b, 3)
});

Ok(config)
}

/// Merge two TOML documents, merging values from `right` onto `left`
///
/// When an array exists in both `left` and `right`, `right`'s array is
/// used. When a table exists in both `left` and `right`, the merged table
/// consists of all keys in `left`'s table unioned with all keys in `right`
/// with the values of `right` being merged recursively onto values of
/// `left`.
///
/// `merge_toplevel_arrays` controls whether a top-level array in the TOML
/// document is merged instead of overridden. This is useful for TOML
/// documents that use a top-level array of values like the `languages.toml`,
/// where one usually wants to override or add to the array instead of
/// replacing it altogether.
///
/// NOTE: This merge function is taken from Helix:
/// https://github.com/helix-editor/helix licensed under MPL-2.0. There
/// it is defined under: helix-loader/src/lib.rs. Taken from commit df09490
pub fn merge_toml_values(left: toml::Value, right: toml::Value, merge_depth: usize) -> toml::Value {
use toml::Value;

fn get_name(v: &Value) -> Option<&str> {
v.get("name").and_then(Value::as_str)
}

match (left, right) {
(Value::Array(mut left_items), Value::Array(right_items)) => {
// The top-level arrays should be merged but nested arrays should
// act as overrides. For the `languages.toml` config, this means
// that you can specify a sub-set of languages in an overriding
// `languages.toml` but that nested arrays like file extensions
// arguments are replaced instead of merged.
if merge_depth > 0 {
left_items.reserve(right_items.len());
for rvalue in right_items {
let lvalue = get_name(&rvalue)
.and_then(|rname| {
left_items.iter().position(|v| get_name(v) == Some(rname))
})
.map(|lpos| left_items.remove(lpos));
let mvalue = match lvalue {
Some(lvalue) => merge_toml_values(lvalue, rvalue, merge_depth - 1),
None => rvalue,
};
left_items.push(mvalue);
}
Value::Array(left_items)
} else {
Value::Array(right_items)
}
}
(Value::Table(mut left_map), Value::Table(right_map)) => {
if merge_depth > 0 {
for (rname, rvalue) in right_map {
match left_map.remove(&rname) {
Some(lvalue) => {
let merged_value = merge_toml_values(lvalue, rvalue, merge_depth - 1);
left_map.insert(rname, merged_value);
}
None => {
left_map.insert(rname, rvalue);
}
}
}
Value::Table(left_map)
} else {
Value::Table(right_map)
}
}
// Catch everything else we didn't handle, and use the right value
(_, value) => value,
}
}

fn find_configuration_dir() -> PathBuf {
ProjectDirs::from("", "", "topiary")
.expect("Could not access the OS's Home directory")
.config_dir()
.to_owned()
}

pub fn find_workspace() -> PathBuf {
let current_dir = current_dir().expect("Could not get current working directory");
for ancestor in current_dir.ancestors() {
if ancestor.join(".topiary").exists() {
return ancestor.to_owned();
}
}

// Default to the current dir if we could not find an ancestor with the .topiary directory
// If current_dir does not contain a .topiary, it will be filtered our in the `user_lang_toml` function.
current_dir
}
18 changes: 18 additions & 0 deletions topiary-cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,21 @@ where
)
}
}

impl From<toml::de::Error> for TopiaryError {
fn from(e: toml::de::Error) -> Self {
TopiaryError::Bin(
"Could not parse user configuration".to_owned(),
Some(CLIError::Generic(Box::new(e))),
)
}
}

impl From<serde_toml_merge::Error> for TopiaryError {
fn from(e: serde_toml_merge::Error) -> Self {
TopiaryError::Bin(
format!("Could not merge the default configuration and user configurations. Error occured while merging: {}", e.path),
None,
)
}
}
15 changes: 13 additions & 2 deletions topiary-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod configuration;
mod error;
mod output;
mod visualise;

use std::{
eprintln,
error::Error,
fs::File,
io::{stdin, BufReader, BufWriter, Read},
Expand All @@ -11,13 +13,14 @@ use std::{
};

use clap::{ArgGroup, Parser};
use configuration::parse_configuration;

use crate::{
error::{CLIError, CLIResult, TopiaryError},
output::OutputFile,
visualise::Visualisation,
};
use topiary::{formatter, Configuration, Language, Operation, SupportedLanguage};
use topiary::{formatter, Language, Operation, SupportedLanguage};

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
Expand Down Expand Up @@ -64,6 +67,10 @@ struct Args {
/// Do not check that formatting twice gives the same output
#[arg(short, long, display_order = 7)]
skip_idempotence: bool,

/// Output the full configuration to stderr before continuing
#[arg(long, display_order = 8)]
output_configuration: bool,
}

#[tokio::main]
Expand All @@ -80,7 +87,11 @@ async fn run() -> CLIResult<()> {
env_logger::init();
let args = Args::parse();

let configuration = Configuration::parse_default_config();
let configuration = parse_configuration()?;

if args.output_configuration {
eprintln!("{:#?}", configuration);
}

// The as_deref() gives us an Option<&str>, which we can match against
// string literals
Expand Down
3 changes: 2 additions & 1 deletion topiary-playground/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ fn main() {
for path in input_files {
let path = path.unwrap().path();
if let Some(ext) = path.extension().map(|ext| ext.to_string_lossy()) {
if !Configuration::parse_default_config()
if !Configuration::parse_default_configuration()
.unwrap()
.known_extensions()
.contains(&*ext)
|| ext == "mli"
Expand Down
2 changes: 1 addition & 1 deletion topiary-playground/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async fn format_inner(
) -> FormatterResult<String> {
let mut output = Vec::new();

let configuration = Configuration::parse_default_config();
let configuration = Configuration::parse_default_configuration()?;
let language = configuration.get_language(language_name)?;
let grammars = language.grammars_wasm().await?;

Expand Down
2 changes: 1 addition & 1 deletion topiary/benches/benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use topiary::{formatter, Operation};
async fn format() {
let input = fs::read_to_string("tests/samples/input/ocaml.ml").unwrap();
let query = fs::read_to_string("../languages/ocaml.scm").unwrap();
let configuration = Configuration::parse_default_config();
let configuration = Configuration::parse_default_configuration().unwrap();
let language = configuration.get_language("ocaml_implementation").unwrap();
let grammars = language.grammars().await.unwrap();

Expand Down
31 changes: 22 additions & 9 deletions topiary/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,14 @@ use std::{collections::HashSet, str::from_utf8};
use crate::{language::Language, FormatterError, FormatterResult};
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
#[derive(Deserialize, Serialize, Debug)]
pub struct Configuration {
pub language: Vec<Language>,
}

impl Configuration {
// TODO: Should be able to take a filepath.
// TODO: Should return a FormatterResult rather than panicking.
#[must_use]
pub fn parse_default_config() -> Self {
let default_config = include_bytes!("../../languages.toml");
let default_config = toml::from_str(from_utf8(default_config).unwrap())
.expect("Could not parse built-in languages.toml");
default_config
pub fn new() -> Self {
Configuration { language: vec![] }
}

#[must_use]
Expand All @@ -40,4 +34,23 @@ impl Configuration {
name.as_ref().to_string(),
));
}

pub fn parse_default_configuration() -> FormatterResult<Self> {
default_configuration_toml()
.try_into()
.map_err(FormatterError::from)
}
}

impl Default for Configuration {
fn default() -> Self {
Self::new()
}
}

/// Default built-in languages.toml.
pub fn default_configuration_toml() -> toml::Value {
let default_config = include_bytes!("../../languages.toml");
toml::from_str(from_utf8(default_config).unwrap())
.expect("Could not parse built-in languages.toml to valid toml")
}
9 changes: 9 additions & 0 deletions topiary/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,12 @@ impl From<tree_sitter_facade::ParserError> for FormatterError {
Self::Internal("Error while parsing".into(), Some(Box::new(e)))
}
}

impl From<toml::de::Error> for FormatterError {
fn from(e: toml::de::Error) -> Self {
Self::Internal(
"Error while parsing the internal configuration file".to_owned(),
Some(Box::new(e)),
)
}
}
Loading

0 comments on commit e5d2db2

Please sign in to comment.