Skip to main content

edikt_env/
lib.rs

1//! edikt `.env` / `.properties` format module.
2//!
3//! Flat, string-valued, honest line-level editing only - no grammar, no
4//! interpolation, no quoting semantics, no inline comments. `key=value` /
5//! `key:value` entries, `#`/`!` comments, and blanks round-trip byte-for-byte.
6//! Paths are a single `.key`; edits change only the targeted value or line.
7
8mod comments;
9mod edit;
10mod parser;
11mod project;
12mod syntax;
13
14pub use comments::emit_commented;
15pub use edikt_core::EditError;
16pub use edit::apply;
17
18use edikt_core::{CommentKind, Document, Expr, Feature, Value};
19use syntax::{Sk, SyntaxNode};
20
21/// Comment kinds this format supports (empty => none); the comment
22/// capability, subsuming the boolean `Feature::Comments`. No inline: a `#`
23/// inside a value is data, not a comment.
24pub const COMMENT_KINDS: &[CommentKind] = &[CommentKind::Head, CommentKind::Foot];
25
26/// Capabilities: comments only. Flat and string-valued - no nesting, arrays,
27/// typed scalars, or sections.
28pub const FEATURES: &[Feature] = &[Feature::Comments];
29
30/// A parse failure.
31#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32#[error("{msg}")]
33pub struct ParseError {
34    pub msg: String,
35}
36
37/// A parsed `.env` / `.properties` document, backed by a lossless CST.
38pub struct Env {
39    root: SyntaxNode,
40}
41
42impl Env {
43    /// Access the underlying syntax tree.
44    pub fn syntax(&self) -> &SyntaxNode {
45        &self.root
46    }
47
48    /// Set the entry `key` to a scalar, format-preserving. If `key` doesn't
49    /// exist, a new `key=value` line is appended.
50    pub fn set(&mut self, key: &str, value: &Value) -> Result<(), EditError> {
51        let text = edit::scalar_string(value)?;
52        match edit::find_entry(&self.root, key) {
53            Some(entry) => {
54                let value_node = entry
55                    .children()
56                    .find(|n| n.kind() == Sk::Value)
57                    .ok_or_else(|| EditError::new("entry has no value slot"))?;
58                let new_root = value_node.replace_with(edit::value_node_green(&text));
59                self.root = SyntaxNode::new_root(new_root);
60            }
61            None => {
62                let mut src = self.to_source();
63                if !src.is_empty() && !src.ends_with('\n') {
64                    src.push('\n');
65                }
66                src.push_str(&format!("{key}={text}\n"));
67                self.root = SyntaxNode::new_root(parser::build(&src));
68            }
69        }
70        Ok(())
71    }
72
73    /// The string value of `key`, or `None`.
74    pub fn value_at(&self, key: &str) -> Option<Value> {
75        edit::find_entry(&self.root, key).map(|e| Value::Str(project::entry_value(&e)))
76    }
77
78    /// Delete `key`, removing its whole line (a missing key is a no-op).
79    pub fn delete(&mut self, key: &str) -> Result<(), EditError> {
80        let root = self.root.clone_for_update();
81        if let Some(entry) = edit::find_entry(&root, key) {
82            entry.detach();
83            self.root = SyntaxNode::new_root(root.green().into_owned());
84        }
85        Ok(())
86    }
87}
88
89/// Parse `.env` / `.properties` source into an [`Env`] document.
90pub fn parse(src: &str) -> Result<Env, ParseError> {
91    let root = SyntaxNode::new_root(parser::build(src));
92    let malformed = root
93        .descendants_with_tokens()
94        .filter_map(|e| e.into_token())
95        .any(|t| t.kind() == Sk::Error);
96    if malformed {
97        return Err(ParseError {
98            msg: "invalid: a line is neither a comment nor key=value".to_string(),
99        });
100    }
101    Ok(Env { root })
102}
103
104impl Document for Env {
105    fn to_source(&self) -> String {
106        edikt_syntax::to_source(&self.root)
107    }
108    fn to_value(&self) -> Value {
109        project::to_value(&self.root)
110    }
111    fn features(&self) -> &'static [Feature] {
112        FEATURES
113    }
114    fn apply(&mut self, expr: &Expr) -> Result<Vec<String>, EditError> {
115        edit::apply(self, expr).map(|()| Vec::new())
116    }
117    fn has_comments(&self) -> bool {
118        self.root
119            .descendants_with_tokens()
120            .filter_map(|e| e.into_token())
121            .any(|t| t.kind() == Sk::Comment)
122    }
123    fn to_commented(&self) -> Option<edikt_core::Commented> {
124        Some(comments::to_commented(&self.root))
125    }
126    fn set_comment(
127        &mut self,
128        path: &[edikt_core::Step],
129        kind: edikt_core::CommentKind,
130        text: &str,
131    ) -> Result<Vec<String>, EditError> {
132        let key = comments::single_key(path)?;
133        let (source, warnings) = comments::set_key_comment(&self.root, key, kind, text)?;
134        self.root = SyntaxNode::new_root(parser::build(&source));
135        Ok(warnings)
136    }
137    fn delete_comment(
138        &mut self,
139        path: &[edikt_core::Step],
140        kind: edikt_core::CommentKind,
141    ) -> Result<(), EditError> {
142        let key = comments::single_key(path)?;
143        let source = comments::delete_key_comment(&self.root, key, kind)?;
144        self.root = SyntaxNode::new_root(parser::build(&source));
145        Ok(())
146    }
147}
148
149/// Emit a value as a flat `.env`: every leaf becomes a `key=value` line, with
150/// nested objects/arrays flattened to dotted keys. Returns the text and warnings.
151/// (The comment-free case of [`emit_commented`].)
152pub fn emit(value: &Value) -> Result<(String, Vec<String>), EditError> {
153    comments::emit_commented(&edikt_core::Commented::from_value(value))
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use edikt_core::eval;
160    use edikt_core::parse as parse_expr;
161
162    const SAMPLE: &str = "# service env\nDATABASE_URL=postgres://localhost/app\nDEBUG = true\nEMPTY=\nWITH_HASH=a#b\n";
163
164    fn q(src: &str, expr: &str) -> Vec<Value> {
165        let v = parse(src).unwrap().to_value();
166        eval(&parse_expr(expr).unwrap(), &v).unwrap()
167    }
168
169    fn edit_src(src: &str, expr: &str) -> String {
170        let mut doc = parse(src).unwrap();
171        apply(&mut doc, &parse_expr(expr).unwrap()).unwrap();
172        doc.to_source()
173    }
174
175    fn cedit(src: &str, expr: &str) -> String {
176        let mut doc = parse(src).unwrap();
177        edikt_core::apply_comment_mutation(&mut doc, &parse_expr(expr).unwrap()).unwrap();
178        doc.to_source()
179    }
180
181    #[test]
182    fn comment_mutation_head_foot_and_inline_refused() {
183        // Head above an entry.
184        assert_eq!(
185            cedit("DATABASE_URL=x\nDEBUG=true\n", ".DEBUG.# = \"verbose\""),
186            "DATABASE_URL=x\n# verbose\nDEBUG=true\n"
187        );
188        // Foot after an entry.
189        assert_eq!(
190            cedit("A=1\nB=2\n", ".B.#.foot = \"end\""),
191            "A=1\nB=2\n# end\n"
192        );
193        // Replace an existing head; delete it.
194        assert_eq!(
195            cedit("# old\nK=v\n", ".K.# |= ascii_upcase"),
196            "# OLD\nK=v\n"
197        );
198        assert_eq!(cedit("# drop\nK=v\n", "del(.K.#)"), "K=v\n");
199        // Inline is refused - `.env` has no inline comments.
200        let mut doc = parse("K=v\n").unwrap();
201        let err = edikt_core::apply_comment_mutation(
202            &mut doc,
203            &parse_expr(".K.#.inline = \"x\"").unwrap(),
204        )
205        .unwrap_err()
206        .to_string();
207        assert!(err.contains("no inline comments"), "got: {err}");
208    }
209
210    #[test]
211    fn roundtrips_byte_identically() {
212        for src in [
213            SAMPLE,
214            "",
215            "KEY=value",
216            "a:1\nb : 2\n",
217            "  spaced = yes  \n# comment\n",
218            "! properties comment\nkey.with.dots=1\n",
219            "A=1\r\nB=2\r\n", // CRLF terminators preserved
220            "A=1\n\nB=2\n",   // a blank line between entries
221            "\n\n",           // blank-only document
222        ] {
223            assert_eq!(parse(src).unwrap().to_source(), src, "round-trip: {src:?}");
224        }
225    }
226
227    #[test]
228    fn projects_flat() {
229        assert_eq!(
230            q(SAMPLE, ".DATABASE_URL"),
231            vec![Value::Str("postgres://localhost/app".into())]
232        );
233        assert_eq!(q(SAMPLE, ".DEBUG"), vec![Value::Str("true".into())]);
234        assert_eq!(q(SAMPLE, ".EMPTY"), vec![Value::Str("".into())]);
235        // No inline-comment parsing: the `#` stays in the value.
236        assert_eq!(q(SAMPLE, ".WITH_HASH"), vec![Value::Str("a#b".into())]);
237    }
238
239    #[test]
240    fn set_preserves_separator_style() {
241        // `DATABASE_URL=...` has no spaces; `DEBUG = true` does. Keep each.
242        assert!(
243            edit_src(SAMPLE, r#".DATABASE_URL = "sqlite://x""#).contains("DATABASE_URL=sqlite://x")
244        );
245        assert!(edit_src(SAMPLE, ".DEBUG = false").contains("DEBUG = false"));
246    }
247
248    #[test]
249    fn del_removes_line_and_keeps_comment() {
250        let out = edit_src(SAMPLE, "del(.DEBUG)");
251        assert!(!out.contains("DEBUG"));
252        assert!(out.contains("# service env"));
253        assert!(out.contains("DATABASE_URL="));
254    }
255
256    #[test]
257    fn del_entries_in_pipeline() {
258        assert_eq!(edit_src("A=1\nB=2\nC=3\n", "del(.A) | del(.B)"), "C=3\n");
259    }
260
261    #[test]
262    fn update_and_add_assign() {
263        assert!(edit_src(SAMPLE, ".DEBUG |= ascii_upcase").contains("DEBUG = TRUE"));
264        assert!(edit_src(SAMPLE, r#".DEBUG += "!""#).contains("DEBUG = true!"));
265    }
266
267    #[test]
268    fn nesting_and_arrays_rejected() {
269        let mut doc = parse(SAMPLE).unwrap();
270        assert!(apply(&mut doc, &parse_expr(".DEBUG = [1]").unwrap()).is_err());
271        assert!(apply(&mut doc, &parse_expr(".a.b = 1").unwrap()).is_err()); // no nesting
272    }
273
274    #[test]
275    fn malformed_line_errors() {
276        assert!(parse("not an entry line\n").is_err());
277    }
278
279    #[test]
280    fn creates_new_key_by_appending() {
281        assert_eq!(edit_src("A=1\n", r#".B = "2""#), "A=1\nB=2\n");
282        // appends even when the file lacks a trailing newline
283        assert_eq!(edit_src("A=1", r#".B = "2""#), "A=1\nB=2\n");
284        // preserves the existing content and comments
285        let out = edit_src(SAMPLE, r#".NEW_FLAG = "on""#);
286        assert!(out.contains("# service env"));
287        assert!(out.ends_with("NEW_FLAG=on\n"));
288    }
289
290    #[test]
291    fn dotted_properties_keys_are_single_keys() {
292        // In `.properties`, `app.name` is one key, addressed with a quoted field.
293        let src = "app.name = edikt\nserver.port: 8080\n";
294        assert_eq!(q(src, r#"."app.name""#), vec![Value::Str("edikt".into())]);
295        assert_eq!(q(src, r#"."server.port""#), vec![Value::Str("8080".into())]);
296        assert!(edit_src(src, r#"."server.port" = "9090""#).contains("server.port: 9090"));
297    }
298
299    // --- comment model (extraction + commented emit) -----------------------
300
301    #[test]
302    fn extracts_head_comments_and_trailing_foot() {
303        let src = "# service env\nDATABASE_URL=x\n# stop here\n";
304        let doc = parse(src).unwrap();
305        let c = doc.to_commented().unwrap();
306        assert_eq!(c.to_value(), doc.to_value(), "shapes must match");
307        let edikt_core::CommentedNode::Object(entries) = &c.node else {
308            panic!("expected object");
309        };
310        assert_eq!(entries[0].1.comments.head, vec!["service env"]);
311        assert_eq!(entries[0].1.comments.foot, vec!["stop here"]);
312    }
313
314    #[test]
315    fn commented_emit_round_trips_and_remaps_inline() {
316        let c = parse(SAMPLE).unwrap().to_commented().unwrap();
317        let (out, warnings) = emit_commented(&c).unwrap();
318        assert!(warnings.is_empty());
319        assert!(out.starts_with("# service env\nDATABASE_URL="));
320        assert_eq!(parse(&out).unwrap().to_commented().unwrap(), c);
321
322        // An inline comment (from a richer format) moves to its own line, and
323        // that remap warns.
324        let mut inline = edikt_core::Commented::from_value(&Value::Object(vec![(
325            "PORT".into(),
326            Value::Str("80".into()),
327        )]));
328        let edikt_core::CommentedNode::Object(entries) = &mut inline.node else {
329            unreachable!();
330        };
331        entries[0].1.comments.inline = Some("the listen port".into());
332        let (out2, warnings2) = emit_commented(&inline).unwrap();
333        assert_eq!(out2, "# the listen port\nPORT=80\n");
334        assert_eq!(warnings2.len(), 1);
335        assert!(
336            warnings2[0].contains("inline comments moved"),
337            "got: {warnings2:?}"
338        );
339    }
340
341    #[test]
342    fn roundtrips_every_fixture() {
343        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/env");
344        let mut count = 0;
345        for entry in std::fs::read_dir(&dir).expect("fixtures/env directory") {
346            let path = entry.unwrap().path();
347            match path.extension().and_then(|e| e.to_str()) {
348                Some("env") | Some("properties") => {}
349                _ => continue,
350            }
351            let src = std::fs::read_to_string(&path).unwrap();
352            assert_eq!(
353                parse(&src).unwrap().to_source(),
354                src,
355                "round-trip must be byte-identical: {}",
356                path.display()
357            );
358            count += 1;
359        }
360        assert!(count >= 2, "expected env fixtures, found {count}");
361    }
362
363    // --- edit dispatch: pipe, del arity, non-assignment ---------------------
364
365    #[test]
366    fn piped_mutations_apply_in_order() {
367        assert_eq!(
368            edit_src("A=1\nB=2\n", r#".A = "x" | .B = "y""#),
369            "A=x\nB=y\n"
370        );
371    }
372
373    #[test]
374    fn del_with_wrong_arity_errors() {
375        // Function args are `;`-separated, so `del(.A; .B)` is two arguments.
376        let mut doc = parse("A=1\nB=2\n").unwrap();
377        let err = apply(&mut doc, &parse_expr("del(.A; .B)").unwrap())
378            .unwrap_err()
379            .to_string();
380        assert!(
381            err.contains("del(...) takes one path argument"),
382            "got: {err}"
383        );
384    }
385
386    #[test]
387    fn a_bare_query_is_not_a_mutation() {
388        let mut doc = parse("A=1\n").unwrap();
389        let err = apply(&mut doc, &parse_expr(".A").unwrap())
390            .unwrap_err()
391            .to_string();
392        assert!(err.contains("expected an assignment"), "got: {err}");
393    }
394
395    // --- comment paths: document-level and nested are refused ---------------
396
397    #[test]
398    fn comment_document_banner_is_a_followup() {
399        let mut doc = parse("A=1\n").unwrap();
400        let err =
401            edikt_core::apply_comment_mutation(&mut doc, &parse_expr(r#".# = "banner""#).unwrap())
402                .unwrap_err()
403                .to_string();
404        assert!(err.contains("document-level"), "got: {err}");
405    }
406
407    #[test]
408    fn nested_comment_path_is_refused() {
409        let mut doc = parse("A=1\n").unwrap();
410        let err =
411            edikt_core::apply_comment_mutation(&mut doc, &parse_expr(r#".a.b.# = "x""#).unwrap())
412                .unwrap_err()
413                .to_string();
414        assert!(
415            err.contains("flat: comment paths are a single"),
416            "got: {err}"
417        );
418    }
419
420    // --- comment deletion: inline and missing-key no-ops --------------------
421
422    #[test]
423    fn deleting_inline_comment_is_a_noop() {
424        // `.env` has no inline comments, so `del(.K.#.inline)` changes nothing.
425        assert_eq!(cedit("# h\nK=v\n", "del(.K.#.inline)"), "# h\nK=v\n");
426    }
427
428    #[test]
429    fn deleting_comment_on_missing_key_is_a_noop() {
430        assert_eq!(cedit("# h\nK=v\n", "del(.NOPE.#)"), "# h\nK=v\n");
431    }
432
433    // --- extraction: trailing comments with no entries ----------------------
434
435    #[test]
436    fn all_comments_no_entries_become_document_foot() {
437        let doc = parse("# just a note\n# and another\n").unwrap();
438        let c = doc.to_commented().unwrap();
439        let edikt_core::CommentedNode::Object(entries) = &c.node else {
440            panic!("expected object");
441        };
442        assert!(entries.is_empty(), "no entries in a comment-only file");
443        assert_eq!(
444            c.comments.foot,
445            vec!["just a note".to_string(), "and another".to_string()]
446        );
447    }
448
449    // --- emission edge cases ------------------------------------------------
450
451    #[test]
452    fn emit_rejects_a_top_level_scalar() {
453        let err = emit(&Value::Str("x".into())).unwrap_err().to_string();
454        assert!(err.contains("requires a top-level object"), "got: {err}");
455    }
456
457    #[test]
458    fn emit_carries_an_entry_foot_comment() {
459        let mut c = edikt_core::Commented::from_value(&Value::Object(vec![(
460            "A".into(),
461            Value::Str("1".into()),
462        )]));
463        let edikt_core::CommentedNode::Object(entries) = &mut c.node else {
464            unreachable!();
465        };
466        entries[0].1.comments.foot.push("tail".into());
467        let (out, warnings) = emit_commented(&c).unwrap();
468        assert_eq!(out, "A=1\n# tail\n");
469        assert!(warnings.is_empty());
470    }
471
472    #[test]
473    fn emit_flattens_nesting_and_warns() {
474        let (out, warnings) = emit(&Value::Object(vec![(
475            "a".into(),
476            Value::Object(vec![("b".into(), Value::Str("1".into()))]),
477        )]))
478        .unwrap();
479        assert_eq!(out, "a.b=1\n");
480        assert_eq!(warnings.len(), 1);
481        assert!(warnings[0].contains("flattened"), "got: {warnings:?}");
482    }
483
484    // --- Document trait surface + syntax accessor ---------------------------
485
486    #[test]
487    fn syntax_accessor_exposes_the_tree() {
488        let doc = parse("A=1\n# c\n").unwrap();
489        assert_eq!(doc.syntax().text().to_string(), "A=1\n# c\n");
490    }
491
492    #[test]
493    fn document_trait_features_comments_and_apply() {
494        let mut doc = parse("# note\nA=1\n").unwrap();
495        assert_eq!(doc.features(), FEATURES);
496        assert!(doc.has_comments());
497        assert!(!parse("A=1\n").unwrap().has_comments());
498        // The trait's `apply` dispatches into the format-preserving edit path.
499        Document::apply(&mut doc, &parse_expr(r#".A = "2""#).unwrap()).unwrap();
500        assert_eq!(doc.to_source(), "# note\nA=2\n");
501    }
502
503    // --- Language mapping invariant -----------------------------------------
504
505    #[test]
506    fn language_kind_mapping_roundtrips() {
507        use rowan::Language;
508        for k in [
509            Sk::Ws,
510            Sk::Newline,
511            Sk::Comment,
512            Sk::Key,
513            Sk::Sep,
514            Sk::ValStr,
515            Sk::Error,
516            Sk::Value,
517            Sk::Entry,
518            Sk::Root,
519        ] {
520            let raw = crate::syntax::EnvLang::kind_to_raw(k);
521            assert_eq!(crate::syntax::EnvLang::kind_from_raw(raw), k);
522        }
523    }
524}