Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read user defined configuration file from $ROOT/.topiary/languages.toml #506

Merged
merged 8 commits into from
Jun 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
nbacquey marked this conversation as resolved.
Show resolved Hide resolved
Some(Box::new(e)),
)
}
}
Loading