1mod 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
21pub const COMMENT_KINDS: &[CommentKind] = &[CommentKind::Head, CommentKind::Foot];
25
26pub const FEATURES: &[Feature] = &[Feature::Comments];
29
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32#[error("{msg}")]
33pub struct ParseError {
34 pub msg: String,
35}
36
37pub struct Env {
39 root: SyntaxNode,
40}
41
42impl Env {
43 pub fn syntax(&self) -> &SyntaxNode {
45 &self.root
46 }
47
48 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 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 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
89pub 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
149pub 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 assert_eq!(
185 cedit("DATABASE_URL=x\nDEBUG=true\n", ".DEBUG.# = \"verbose\""),
186 "DATABASE_URL=x\n# verbose\nDEBUG=true\n"
187 );
188 assert_eq!(
190 cedit("A=1\nB=2\n", ".B.#.foot = \"end\""),
191 "A=1\nB=2\n# end\n"
192 );
193 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 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", "A=1\n\nB=2\n", "\n\n", ] {
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 assert_eq!(q(SAMPLE, ".WITH_HASH"), vec![Value::Str("a#b".into())]);
237 }
238
239 #[test]
240 fn set_preserves_separator_style() {
241 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()); }
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 assert_eq!(edit_src("A=1", r#".B = "2""#), "A=1\nB=2\n");
284 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 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 #[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 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 #[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 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 #[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 #[test]
423 fn deleting_inline_comment_is_a_noop() {
424 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 #[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 #[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 #[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 Document::apply(&mut doc, &parse_expr(r#".A = "2""#).unwrap()).unwrap();
500 assert_eq!(doc.to_source(), "# note\nA=2\n");
501 }
502
503 #[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}