1mod comments;
9mod edit;
10mod parser;
11mod project;
12mod syntax;
13
14pub use comments::{emit_commented, emit_commented_with};
15pub use edit::apply;
16pub use parser::Dialect;
17
18pub use edikt_core::{
23 CommentKind, Commented, Document, EditError, Expr, Feature, Step, Value, json,
24 parse as parse_expr,
25};
26use syntax::{Sk, SyntaxNode};
27
28pub const COMMENT_KINDS: &[CommentKind] = &[CommentKind::Head, CommentKind::Foot];
32
33pub const FEATURES: &[Feature] = &[Feature::Comments];
36
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39#[error("{msg}")]
40pub struct ParseError {
41 pub msg: String,
42}
43
44pub struct Env {
46 root: SyntaxNode,
47 dialect: Dialect,
51}
52
53impl Env {
54 pub fn syntax(&self) -> &SyntaxNode {
56 &self.root
57 }
58
59 pub fn set(&mut self, key: &str, value: &Value) -> Result<(), EditError> {
62 let text = edit::scalar_string(value)?;
63 match edit::find_entry(&self.root, key) {
64 Some(entry) => {
65 let value_node = entry
66 .children()
67 .find(|n| n.kind() == Sk::Value)
68 .ok_or_else(|| EditError::new("entry has no value slot"))?;
69 let new_root = value_node.replace_with(edit::value_node_green(&text));
70 self.root = SyntaxNode::new_root(new_root);
71 }
72 None => {
73 let mut src = self.to_source();
74 if !src.is_empty() && !src.ends_with('\n') {
75 src.push('\n');
76 }
77 let sep = match self.dialect {
80 parser::Dialect::Punctuated => "=",
81 parser::Dialect::Spaced => " ",
82 };
83 src.push_str(&format!("{key}{sep}{text}\n"));
84 self.root = SyntaxNode::new_root(parser::build(&src, self.dialect));
85 }
86 }
87 Ok(())
88 }
89
90 pub fn value_at(&self, key: &str) -> Option<Value> {
92 edit::find_entry(&self.root, key).map(|e| Value::Str(project::entry_value(&e)))
93 }
94
95 pub fn delete(&mut self, key: &str) -> Result<(), EditError> {
97 let root = self.root.clone_for_update();
98 if let Some(entry) = edit::find_entry(&root, key) {
99 entry.detach();
100 self.root = SyntaxNode::new_root(root.green().into_owned());
101 }
102 Ok(())
103 }
104}
105
106pub fn parse(src: &str) -> Result<Env, ParseError> {
108 parse_with(src, Dialect::Punctuated)
109}
110
111pub fn parse_spaced(src: &str) -> Result<Env, ParseError> {
119 parse_with(src, Dialect::Spaced)
120}
121
122pub fn parse_with(src: &str, dialect: Dialect) -> Result<Env, ParseError> {
124 let root = SyntaxNode::new_root(parser::build(src, dialect));
125 let malformed = root
126 .descendants_with_tokens()
127 .filter_map(|e| e.into_token())
128 .any(|t| t.kind() == Sk::Error);
129 if malformed {
130 return Err(ParseError {
131 msg: "invalid: a line is neither a comment nor key=value".to_string(),
132 });
133 }
134 Ok(Env { root, dialect })
135}
136
137impl Document for Env {
138 fn to_source(&self) -> String {
139 edikt_syntax::to_source(&self.root)
140 }
141 fn to_value(&self) -> Value {
142 project::to_value(&self.root)
143 }
144 fn features(&self) -> &'static [Feature] {
145 FEATURES
146 }
147 fn apply(&mut self, expr: &Expr) -> Result<Vec<String>, EditError> {
148 edit::apply(self, expr).map(|()| Vec::new())
149 }
150 fn has_comments(&self) -> bool {
151 self.root
152 .descendants_with_tokens()
153 .filter_map(|e| e.into_token())
154 .any(|t| t.kind() == Sk::Comment)
155 }
156 fn to_commented(&self) -> Option<edikt_core::Commented> {
157 Some(comments::to_commented(&self.root))
158 }
159 fn set_comment(
160 &mut self,
161 path: &[edikt_core::Step],
162 kind: edikt_core::CommentKind,
163 text: &str,
164 ) -> Result<Vec<String>, EditError> {
165 let key = comments::single_key(path)?;
166 let (source, warnings) = comments::set_key_comment(&self.root, key, kind, text)?;
167 self.root = SyntaxNode::new_root(parser::build(&source, self.dialect));
168 Ok(warnings)
169 }
170 fn delete_comment(
171 &mut self,
172 path: &[edikt_core::Step],
173 kind: edikt_core::CommentKind,
174 ) -> Result<(), EditError> {
175 let key = comments::single_key(path)?;
176 let source = comments::delete_key_comment(&self.root, key, kind)?;
177 self.root = SyntaxNode::new_root(parser::build(&source, self.dialect));
178 Ok(())
179 }
180}
181
182pub fn emit(value: &Value) -> Result<(String, Vec<String>), EditError> {
186 comments::emit_commented(&edikt_core::Commented::from_value(value))
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use edikt_core::eval;
193 use edikt_core::parse as parse_expr;
194
195 const SAMPLE: &str = "# service env\nDATABASE_URL=postgres://localhost/app\nDEBUG = true\nEMPTY=\nWITH_HASH=a#b\n";
196
197 fn q(src: &str, expr: &str) -> Vec<Value> {
198 let v = parse(src).unwrap().to_value();
199 eval(&parse_expr(expr).unwrap(), &v).unwrap()
200 }
201
202 fn edit_src(src: &str, expr: &str) -> String {
203 let mut doc = parse(src).unwrap();
204 apply(&mut doc, &parse_expr(expr).unwrap()).unwrap();
205 doc.to_source()
206 }
207
208 fn cedit(src: &str, expr: &str) -> String {
209 let mut doc = parse(src).unwrap();
210 edikt_core::apply_comment_mutation(&mut doc, &parse_expr(expr).unwrap()).unwrap();
211 doc.to_source()
212 }
213
214 #[test]
215 fn comment_mutation_head_foot_and_inline_refused() {
216 assert_eq!(
218 cedit("DATABASE_URL=x\nDEBUG=true\n", ".DEBUG.# = \"verbose\""),
219 "DATABASE_URL=x\n# verbose\nDEBUG=true\n"
220 );
221 assert_eq!(
223 cedit("A=1\nB=2\n", ".B.#.foot = \"end\""),
224 "A=1\nB=2\n# end\n"
225 );
226 assert_eq!(
228 cedit("# old\nK=v\n", ".K.# |= ascii_upcase"),
229 "# OLD\nK=v\n"
230 );
231 assert_eq!(cedit("# drop\nK=v\n", "del(.K.#)"), "K=v\n");
232 let mut doc = parse("K=v\n").unwrap();
234 let err = edikt_core::apply_comment_mutation(
235 &mut doc,
236 &parse_expr(".K.#.inline = \"x\"").unwrap(),
237 )
238 .unwrap_err()
239 .to_string();
240 assert!(err.contains("no inline comments"), "got: {err}");
241 }
242
243 #[test]
244 fn roundtrips_byte_identically() {
245 for src in [
246 SAMPLE,
247 "",
248 "KEY=value",
249 "a:1\nb : 2\n",
250 " spaced = yes \n# comment\n",
251 "! properties comment\nkey.with.dots=1\n",
252 "A=1\r\nB=2\r\n", "A=1\n\nB=2\n", "\n\n", ] {
256 assert_eq!(parse(src).unwrap().to_source(), src, "round-trip: {src:?}");
257 }
258 }
259
260 #[test]
261 fn projects_flat() {
262 assert_eq!(
263 q(SAMPLE, ".DATABASE_URL"),
264 vec![Value::Str("postgres://localhost/app".into())]
265 );
266 assert_eq!(q(SAMPLE, ".DEBUG"), vec![Value::Str("true".into())]);
267 assert_eq!(q(SAMPLE, ".EMPTY"), vec![Value::Str("".into())]);
268 assert_eq!(q(SAMPLE, ".WITH_HASH"), vec![Value::Str("a#b".into())]);
270 }
271
272 #[test]
273 fn set_preserves_separator_style() {
274 assert!(
276 edit_src(SAMPLE, r#".DATABASE_URL = "sqlite://x""#).contains("DATABASE_URL=sqlite://x")
277 );
278 assert!(edit_src(SAMPLE, ".DEBUG = false").contains("DEBUG = false"));
279 }
280
281 #[test]
282 fn del_removes_line_and_keeps_comment() {
283 let out = edit_src(SAMPLE, "del(.DEBUG)");
284 assert!(!out.contains("DEBUG"));
285 assert!(out.contains("# service env"));
286 assert!(out.contains("DATABASE_URL="));
287 }
288
289 #[test]
290 fn del_entries_in_pipeline() {
291 assert_eq!(edit_src("A=1\nB=2\nC=3\n", "del(.A) | del(.B)"), "C=3\n");
292 }
293
294 #[test]
295 fn update_and_add_assign() {
296 assert!(edit_src(SAMPLE, ".DEBUG |= ascii_upcase").contains("DEBUG = TRUE"));
297 assert!(edit_src(SAMPLE, r#".DEBUG += "!""#).contains("DEBUG = true!"));
298 }
299
300 #[test]
301 fn nesting_and_arrays_rejected() {
302 let mut doc = parse(SAMPLE).unwrap();
303 assert!(apply(&mut doc, &parse_expr(".DEBUG = [1]").unwrap()).is_err());
304 assert!(apply(&mut doc, &parse_expr(".a.b = 1").unwrap()).is_err()); }
306
307 #[test]
308 fn malformed_line_errors() {
309 assert!(parse("not an entry line\n").is_err());
310 }
311
312 #[test]
313 fn creates_new_key_by_appending() {
314 assert_eq!(edit_src("A=1\n", r#".B = "2""#), "A=1\nB=2\n");
315 assert_eq!(edit_src("A=1", r#".B = "2""#), "A=1\nB=2\n");
317 let out = edit_src(SAMPLE, r#".NEW_FLAG = "on""#);
319 assert!(out.contains("# service env"));
320 assert!(out.ends_with("NEW_FLAG=on\n"));
321 }
322
323 #[test]
324 fn dotted_properties_keys_are_single_keys() {
325 let src = "app.name = edikt\nserver.port: 8080\n";
327 assert_eq!(q(src, r#"."app.name""#), vec![Value::Str("edikt".into())]);
328 assert_eq!(q(src, r#"."server.port""#), vec![Value::Str("8080".into())]);
329 assert!(edit_src(src, r#"."server.port" = "9090""#).contains("server.port: 9090"));
330 }
331
332 #[test]
335 fn extracts_head_comments_and_trailing_foot() {
336 let src = "# service env\nDATABASE_URL=x\n# stop here\n";
337 let doc = parse(src).unwrap();
338 let c = doc.to_commented().unwrap();
339 assert_eq!(c.to_value(), doc.to_value(), "shapes must match");
340 let edikt_core::CommentedNode::Object(entries) = &c.node else {
341 panic!("expected object");
342 };
343 assert_eq!(entries[0].1.comments.head, vec!["service env"]);
344 assert_eq!(entries[0].1.comments.foot, vec!["stop here"]);
345 }
346
347 #[test]
348 fn commented_emit_round_trips_and_remaps_inline() {
349 let c = parse(SAMPLE).unwrap().to_commented().unwrap();
350 let (out, warnings) = emit_commented(&c).unwrap();
351 assert!(warnings.is_empty());
352 assert!(out.starts_with("# service env\nDATABASE_URL="));
353 assert_eq!(parse(&out).unwrap().to_commented().unwrap(), c);
354
355 let mut inline = edikt_core::Commented::from_value(&Value::Object(vec![(
358 "PORT".into(),
359 Value::Str("80".into()),
360 )]));
361 let edikt_core::CommentedNode::Object(entries) = &mut inline.node else {
362 unreachable!();
363 };
364 entries[0].1.comments.inline = Some("the listen port".into());
365 let (out2, warnings2) = emit_commented(&inline).unwrap();
366 assert_eq!(out2, "# the listen port\nPORT=80\n");
367 assert_eq!(warnings2.len(), 1);
368 assert!(
369 warnings2[0].contains("inline comments moved"),
370 "got: {warnings2:?}"
371 );
372 }
373
374 #[test]
375 fn roundtrips_every_fixture() {
376 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/env");
377 let mut count = 0;
378 for entry in std::fs::read_dir(&dir).expect("fixtures/env directory") {
379 let path = entry.unwrap().path();
380 let dialect = match path.extension().and_then(|e| e.to_str()) {
384 Some("env") | Some("properties") => Dialect::Punctuated,
385 Some("envspaced") => Dialect::Spaced,
386 _ => continue,
387 };
388 let src = std::fs::read_to_string(&path).unwrap();
389 assert_eq!(
390 parse_with(&src, dialect).unwrap().to_source(),
391 src,
392 "round-trip must be byte-identical: {}",
393 path.display()
394 );
395 count += 1;
396 }
397 assert!(count >= 2, "expected env fixtures, found {count}");
398 }
399
400 #[test]
403 fn piped_mutations_apply_in_order() {
404 assert_eq!(
405 edit_src("A=1\nB=2\n", r#".A = "x" | .B = "y""#),
406 "A=x\nB=y\n"
407 );
408 }
409
410 #[test]
411 fn del_with_wrong_arity_errors() {
412 let mut doc = parse("A=1\nB=2\n").unwrap();
414 let err = apply(&mut doc, &parse_expr("del(.A; .B)").unwrap())
415 .unwrap_err()
416 .to_string();
417 assert!(
418 err.contains("del(...) takes one path argument"),
419 "got: {err}"
420 );
421 }
422
423 #[test]
424 fn a_bare_query_is_not_a_mutation() {
425 let mut doc = parse("A=1\n").unwrap();
426 let err = apply(&mut doc, &parse_expr(".A").unwrap())
427 .unwrap_err()
428 .to_string();
429 assert!(err.contains("expected an assignment"), "got: {err}");
430 }
431
432 #[test]
435 fn comment_document_banner_is_a_followup() {
436 let mut doc = parse("A=1\n").unwrap();
437 let err =
438 edikt_core::apply_comment_mutation(&mut doc, &parse_expr(r#".# = "banner""#).unwrap())
439 .unwrap_err()
440 .to_string();
441 assert!(err.contains("document-level"), "got: {err}");
442 }
443
444 #[test]
445 fn nested_comment_path_is_refused() {
446 let mut doc = parse("A=1\n").unwrap();
447 let err =
448 edikt_core::apply_comment_mutation(&mut doc, &parse_expr(r#".a.b.# = "x""#).unwrap())
449 .unwrap_err()
450 .to_string();
451 assert!(
452 err.contains("flat: comment paths are a single"),
453 "got: {err}"
454 );
455 }
456
457 #[test]
460 fn deleting_inline_comment_is_a_noop() {
461 assert_eq!(cedit("# h\nK=v\n", "del(.K.#.inline)"), "# h\nK=v\n");
463 }
464
465 #[test]
466 fn deleting_comment_on_missing_key_is_a_noop() {
467 assert_eq!(cedit("# h\nK=v\n", "del(.NOPE.#)"), "# h\nK=v\n");
468 }
469
470 #[test]
473 fn all_comments_no_entries_become_document_foot() {
474 let doc = parse("# just a note\n# and another\n").unwrap();
475 let c = doc.to_commented().unwrap();
476 let edikt_core::CommentedNode::Object(entries) = &c.node else {
477 panic!("expected object");
478 };
479 assert!(entries.is_empty(), "no entries in a comment-only file");
480 assert_eq!(
481 c.comments.foot,
482 vec!["just a note".to_string(), "and another".to_string()]
483 );
484 }
485
486 #[test]
489 fn emit_rejects_a_top_level_scalar() {
490 let err = emit(&Value::Str("x".into())).unwrap_err().to_string();
491 assert!(err.contains("requires a top-level object"), "got: {err}");
492 }
493
494 #[test]
495 fn emit_carries_an_entry_foot_comment() {
496 let mut c = edikt_core::Commented::from_value(&Value::Object(vec![(
497 "A".into(),
498 Value::Str("1".into()),
499 )]));
500 let edikt_core::CommentedNode::Object(entries) = &mut c.node else {
501 unreachable!();
502 };
503 entries[0].1.comments.foot.push("tail".into());
504 let (out, warnings) = emit_commented(&c).unwrap();
505 assert_eq!(out, "A=1\n# tail\n");
506 assert!(warnings.is_empty());
507 }
508
509 #[test]
510 fn emit_flattens_nesting_and_warns() {
511 let (out, warnings) = emit(&Value::Object(vec![(
512 "a".into(),
513 Value::Object(vec![("b".into(), Value::Str("1".into()))]),
514 )]))
515 .unwrap();
516 assert_eq!(out, "a.b=1\n");
517 assert_eq!(warnings.len(), 1);
518 assert!(warnings[0].contains("flattened"), "got: {warnings:?}");
519 }
520
521 #[test]
524 fn syntax_accessor_exposes_the_tree() {
525 let doc = parse("A=1\n# c\n").unwrap();
526 assert_eq!(doc.syntax().text().to_string(), "A=1\n# c\n");
527 }
528
529 #[test]
530 fn document_trait_features_comments_and_apply() {
531 let mut doc = parse("# note\nA=1\n").unwrap();
532 assert_eq!(doc.features(), FEATURES);
533 assert!(doc.has_comments());
534 assert!(!parse("A=1\n").unwrap().has_comments());
535 Document::apply(&mut doc, &parse_expr(r#".A = "2""#).unwrap()).unwrap();
537 assert_eq!(doc.to_source(), "# note\nA=2\n");
538 }
539
540 #[test]
543 fn language_kind_mapping_roundtrips() {
544 use rowan::Language;
545 for k in [
546 Sk::Ws,
547 Sk::Newline,
548 Sk::Comment,
549 Sk::Key,
550 Sk::Sep,
551 Sk::ValStr,
552 Sk::Error,
553 Sk::Value,
554 Sk::Entry,
555 Sk::Root,
556 ] {
557 let raw = crate::syntax::EnvLang::kind_to_raw(k);
558 assert_eq!(crate::syntax::EnvLang::kind_from_raw(raw), k);
559 }
560 }
561
562 const SSHD: &str = "# managed\nPort 22\nPermitRootLogin\tyes\n\nHostKey /etc/ssh/k\n";
565
566 #[test]
567 fn envspaced_parses_and_round_trips_byte_identically() {
568 let doc = parse_spaced(SSHD).unwrap();
569 assert_eq!(doc.to_source(), SSHD);
570 }
571
572 #[test]
573 fn envspaced_reads_values_across_separator_spellings() {
574 let doc = parse_spaced(SSHD).unwrap();
575 assert_eq!(doc.value_at("Port"), Some(Value::Str("22".into())));
577 assert_eq!(
578 doc.value_at("PermitRootLogin"),
579 Some(Value::Str("yes".into()))
580 );
581 assert_eq!(
582 doc.value_at("HostKey"),
583 Some(Value::Str("/etc/ssh/k".into()))
584 );
585 }
586
587 #[test]
588 fn envspaced_value_keeps_its_internal_spaces() {
589 let doc = parse_spaced("Subsystem sftp /usr/lib/sftp-server\n").unwrap();
591 assert_eq!(
592 doc.value_at("Subsystem"),
593 Some(Value::Str("sftp /usr/lib/sftp-server".into()))
594 );
595 }
596
597 #[test]
598 fn envspaced_edit_preserves_the_separator_it_found() {
599 let mut doc = parse_spaced(SSHD).unwrap();
602 doc.set("PermitRootLogin", &Value::Str("no".into()))
603 .unwrap();
604 assert!(doc.to_source().contains("PermitRootLogin\tno"));
605 assert!(doc.to_source().contains("HostKey /etc/ssh/k"));
607 assert!(doc.to_source().contains("# managed"));
608 }
609
610 #[test]
611 fn envspaced_append_uses_a_space_not_an_equals() {
612 let mut doc = parse_spaced(SSHD).unwrap();
615 doc.set("MaxAuthTries", &Value::Str("3".into())).unwrap();
616 assert!(doc.to_source().contains("MaxAuthTries 3"));
617 assert!(!doc.to_source().contains("MaxAuthTries="));
618 assert_eq!(
620 parse_spaced(&doc.to_source())
621 .unwrap()
622 .value_at("MaxAuthTries"),
623 Some(Value::Str("3".into()))
624 );
625 }
626
627 #[test]
628 fn the_two_dialects_do_not_read_each_others_files() {
629 assert!(parse_spaced("PORT=22\n").is_err());
633 assert!(parse("Port 22\n").is_err());
634 }
635
636 #[test]
637 fn envspaced_deletes_a_whole_line() {
638 let mut doc = parse_spaced(SSHD).unwrap();
639 doc.delete("Port").unwrap();
640 assert!(!doc.to_source().contains("Port"));
641 assert!(doc.to_source().contains("HostKey"));
642 }
643}