ript_config/
lib.rs

1//! The single place for `ript.toml` configuration.
2//!
3//! Any crates that want to read the config file can do so here to prevent one place having
4//! to load the config and pass it around everywhere.
5
6use std::collections::HashMap;
7use std::fs::File;
8use std::io::{self, Write};
9use std::path::PathBuf;
10use std::str::FromStr;
11use std::{env, fs};
12
13use anyhow::Context;
14use serde::{Deserialize, Serialize};
15
16/// The config file itself.
17#[derive(Deserialize, Serialize)]
18pub struct RiptDotToml {
19    /// The path to the TypeScript file that will be generated by the compiler driver.
20    #[serde(default = "default_outfile")]
21    outfile: PathBuf,
22
23    /// Types are already pascal case, and fields have casings determined by serde.
24    /// Namespace names come from module names, which are always snake case.
25    /// Setting this to true will automatically camelize the namespace names.
26    #[serde(default = "default_camelize_namespaces")]
27    camelize_namespaces: bool,
28
29    /// Override a output with a typescript keyword type. In the future there should definitely be
30    /// a more detailed way to do this, but this works well for now.
31    ///
32    /// The key should be a fully qualified rust path; for example: `my_crate::my_module::MyType`
33    #[serde(default = "default_overrides")]
34    overrides: HashMap<String, TsKeywordType>,
35
36    /// Whether to omit the crate name from the generated TypeScript file.
37    #[serde(default)]
38    omit_crate_name: bool,
39
40    #[serde(default = "default_ript_preamble_namespace")]
41    ript_preamble_namespace: String,
42}
43
44impl FromStr for RiptDotToml {
45    type Err = toml::de::Error;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        toml::from_str(s)
49    }
50}
51
52fn default_outfile() -> PathBuf {
53    PathBuf::from("target/inertia.ts")
54}
55
56fn default_camelize_namespaces() -> bool {
57    true
58}
59
60fn default_overrides() -> HashMap<String, TsKeywordType> {
61    HashMap::new()
62}
63
64fn default_ript_preamble_namespace() -> String {
65    "ript::ript".to_string()
66}
67
68impl Default for RiptDotToml {
69    fn default() -> Self {
70        Self {
71            outfile: default_outfile(),
72            camelize_namespaces: default_camelize_namespaces(),
73            overrides: default_overrides(),
74            omit_crate_name: Default::default(),
75            ript_preamble_namespace: default_ript_preamble_namespace(),
76        }
77    }
78}
79
80impl RiptDotToml {
81    /// Provision the config file if it doesn't exist and return its path.
82    ///
83    /// This deliberately does not create an instance of `Self`, as we don't want to read the source files
84    /// yet. Instead, the driver is responsible for creating them through the compiler sess. We could technically
85    /// do that here and bring in the driver as a dep, but that would prevent `ript-cli` from using this,
86    /// and we want the CLI to remain usable on stable hence the reason for this existing as a separate crate
87    /// and offloading the reading / writing to consumers.
88    pub fn provision() -> anyhow::Result<PathBuf> {
89        let path = ript_dot_doml()?;
90
91        if !path.exists() {
92            std::fs::write(&path, toml::to_string(&Self::default())?)?;
93        }
94
95        Ok(path)
96    }
97
98    pub fn ript_preamble_namespace(&self) -> &str {
99        &self.ript_preamble_namespace
100    }
101
102    /// Get a writer for the generated TypeScript file
103    pub fn outfile_writer(&self) -> anyhow::Result<TsWriter> {
104        TsWriter::new(&self.outfile)
105    }
106
107    /// Returns the generated TS path. This should not be used directly
108    /// by the compiler driver for output, but should be used by the driver
109    /// to add this file to the sourcemap so that changing it will trigger
110    /// rebuilds.
111    pub fn outfile(&self) -> &PathBuf {
112        &self.outfile
113    }
114
115    // /// The path of the opened config file itself.
116    // pub fn path(&self) -> &PathBuf {
117    //     &self.path
118    // }
119
120    /// Whether to camelize the namespace names.
121    pub fn camelize_namespaces(&self) -> bool {
122        self.camelize_namespaces
123    }
124
125    pub fn overrides(&self) -> &HashMap<String, TsKeywordType> {
126        &self.overrides
127    }
128
129    /// Whether to omit the crate name from the generated TypeScript file.
130    pub fn omit_crate_name(&self) -> bool {
131        self.omit_crate_name
132    }
133}
134
135fn ript_dot_doml() -> anyhow::Result<PathBuf> {
136    let workspace_root = detect_workspace_root()?;
137    Ok(workspace_root.join("ript.toml"))
138}
139
140/// Attempt to find the workspace root, or just the crate directory itself
141/// if running in a non-workspace
142pub fn detect_workspace_root() -> anyhow::Result<PathBuf> {
143    let mut current_dir =
144        env::current_dir().context("failed to determine the current working directory")?;
145
146    let mut last_cargo_toml: Option<PathBuf> = None;
147
148    loop {
149        let cargo_toml_path = current_dir.join("Cargo.toml");
150        if cargo_toml_path.exists() {
151            // record this Cargo.toml in case we don't find a `[workspace]` anywhere
152            last_cargo_toml = Some(cargo_toml_path.clone());
153
154            // Naive check for `[workspace]` in the contents
155            if let Ok(contents) = fs::read_to_string(&cargo_toml_path) {
156                // If any line, trimmed of leading space, starts with "[workspace]" we assume it's a workspace root
157                if contents
158                    .lines()
159                    .any(|line| line.trim_start().starts_with("[workspace]"))
160                {
161                    return Ok(current_dir);
162                }
163            }
164        }
165
166        // move one directory up; if we can't then we're done searching
167        if !current_dir.pop() {
168            break;
169        }
170    }
171
172    // if we found at least one Cargo.toml return its directory
173    if let Some(path) = last_cargo_toml {
174        return Ok(path.parent().context("no parent")?.to_path_buf());
175    }
176
177    // otherwise just fall back to the current working directory
178    // env::current_dir()
179
180    anyhow::bail!("could not find a Cargo.toml in any parent directories")
181}
182
183/// A writer for the generated TypeScript file that implements std::io::Write
184pub struct TsWriter {
185    file: File,
186}
187
188impl TsWriter {
189    /// Create a new TsWriter from a path
190    fn new(path: &PathBuf) -> anyhow::Result<Self> {
191        // create required directories for the generated ts if they don't exist
192        if let Some(parent_dir) = path.parent() {
193            fs::create_dir_all(parent_dir)?;
194        }
195
196        Ok(Self {
197            file: File::create(path)?,
198        })
199    }
200}
201
202impl Write for TsWriter {
203    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
204        self.file.write(buf)
205    }
206
207    fn flush(&mut self) -> io::Result<()> {
208        self.file.flush()
209    }
210}
211
212/// A configurable TS keyword type
213#[derive(Deserialize, Serialize, Clone, Copy)]
214#[serde(rename_all = "camelCase")]
215pub enum TsKeywordType {
216    /// A string
217    String,
218    /// A number
219    Number,
220}