Skip to main content

mdtask_core/
parse.rs

1use crate::model::{Arg, Job, KNOWN_FILE_OPTS, KNOWN_OPTS, Requirement, TaskFile};
2use crate::run::is_known_lang;
3
4/// Parse a markdown task file. It is line-based (no CommonMark dependency): a
5/// heading starts a job, the first fenced block under it is the script, and
6/// `Key: value` lines set metadata. Parsing is infallible; problems are reported
7/// in [`TaskFile::warnings`] rather than dropped to silence. CRLF endings are
8/// normalized.
9pub fn parse(src: &str) -> TaskFile {
10    let mut file = TaskFile::default();
11    let mut cur: Option<Job> = None;
12    let mut in_fence = false;
13    let mut fence_marker = Fence { ch: b'`', len: 3 };
14    let mut have_script = false; // first fence per job only
15    let mut script = String::new();
16    // Whether a `Key: value` line here is metadata or just a sentence that
17    // happens to start that way. See `apply_line`.
18    let mut block_start = true;
19
20    for raw in src.split('\n') {
21        let line = raw.strip_suffix('\r').unwrap_or(raw); // normalize CRLF
22        if in_fence {
23            // A fence is closed only by a BARE marker line (CommonMark): ` ``` `
24            // with an info string opens, it does not close, so a stray fence-open
25            // cannot accidentally terminate an unterminated block early.
26            if is_closing_fence(line, fence_marker) {
27                in_fence = false;
28                block_start = true; // a fence is a block boundary
29                if let Some(t) = cur.as_mut()
30                    && !have_script
31                {
32                    t.script = std::mem::take(&mut script);
33                    have_script = true;
34                }
35                script.clear();
36            } else if cur.is_some() && !have_script {
37                script.push_str(line);
38                script.push('\n');
39            }
40            continue;
41        }
42        if let Some(marker) = opening_fence(line) {
43            in_fence = true;
44            block_start = true;
45            fence_marker = marker;
46            if let Some(t) = cur.as_mut()
47                && !have_script
48            {
49                t.lang = info_string(line, marker);
50            }
51            script.clear();
52            continue;
53        }
54        if let Some(name) = heading(line) {
55            finalize(cur.take(), &mut file);
56            cur = Some(Job {
57                name,
58                ..Job::default()
59            });
60            have_script = false;
61            block_start = true;
62            continue;
63        }
64        if line.trim().is_empty() {
65            block_start = true;
66            // Keep the paragraph break. Descriptions used to be a run of lines
67            // with every blank dropped, so nothing downstream could tell where
68            // the opening thought ended: a listing had no first paragraph to
69            // show, only a first hard-wrapped line, which is a fragment.
70            if let Some(t) = cur.as_mut()
71                && !t.description.is_empty()
72                && !t.description.ends_with("\n\n")
73            {
74                t.description.push('\n');
75            }
76            continue;
77        }
78        let was_meta = apply_line(
79            line,
80            cur.as_mut(),
81            &mut file.env,
82            &mut file.opts,
83            &mut file.warnings,
84            block_start,
85        );
86        // A metadata line does not end the run, so `Args:` and `Requires:` can
87        // sit together. Nor does a list item, which opens a block of its own and
88        // is how an indented `Env:` under a bullet stays reachable. An ordinary
89        // sentence does end it: what follows a sentence is its continuation.
90        block_start = was_meta || opens_list_item(line);
91    }
92    // An unterminated fence at EOF: still capture the script so the job is not
93    // lost, but warn, since a forgotten closing fence is a common authoring slip.
94    if in_fence {
95        if let Some(t) = cur.as_mut()
96            && !have_script
97        {
98            t.script = std::mem::take(&mut script);
99        }
100        let name = cur.as_ref().map(|t| t.name.clone()).unwrap_or_default();
101        file.warnings
102            .push(format!("unterminated code fence in task {name:?}"));
103    }
104    finalize(cur.take(), &mut file);
105    file
106}
107
108/// Finalize a heading into the file. A heading with a script is a job; one without
109/// (a `# Tasks` section) is not, but its `Env:` hoists to all jobs. Records
110/// warnings for a duplicate name or an unknown fence language.
111fn finalize(job: Option<Job>, file: &mut TaskFile) {
112    let Some(mut t) = job else {
113        return;
114    };
115    if t.script.is_empty() {
116        file.env.append(&mut t.env); // section heading, so hoist its env
117        return;
118    }
119    t.description = t.description.trim().to_string();
120    if file.jobs.iter().any(|x| x.name == t.name) {
121        file.warnings.push(format!(
122            "duplicate task {:?}; the first defined wins",
123            t.name
124        ));
125    }
126    if !is_known_lang(&t.lang) {
127        file.warnings.push(format!(
128            "task {:?}: fenced language {:?} is not a known interpreter; \
129             running as a strict sh script",
130            t.name, t.lang
131        ));
132    }
133    file.jobs.push(t);
134}
135
136/// An open fence: which character, and how many of them.
137///
138/// The length matters. CommonMark closes a fence only with a run of *at least
139/// as many* of the same character, which is how a markdown block can contain
140/// markdown blocks: fence the outer one with four backticks and the inner
141/// three-backtick fences are ordinary content. Discarding the length made every
142/// fence three long, so an inner ``` closed the outer block early. mdtask's own
143/// README, whose opening example is markdown inside markdown, parsed as a task
144/// named "mdtask" whose script was the first half of the sample and whose
145/// language was "`markdown".
146#[derive(Clone, Copy, PartialEq, Eq)]
147struct Fence {
148    ch: u8,
149    len: usize,
150}
151
152/// The opening fence if `line` starts one, else `None`.
153fn opening_fence(line: &str) -> Option<Fence> {
154    let t = line.trim_start();
155    let ch = match t.as_bytes().first() {
156        Some(b'`') => b'`',
157        Some(b'~') => b'~',
158        _ => return None,
159    };
160    let len = t.bytes().take_while(|b| *b == ch).count();
161    if len < 3 {
162        return None;
163    }
164    // A backtick fence's info string may not itself contain a backtick, which is
165    // what keeps inline code (`` `x` ``) from opening a block.
166    if ch == b'`' && t[len..].contains('`') {
167        return None;
168    }
169    Some(Fence { ch, len })
170}
171
172/// Whether `line` closes `fence`: only the fence character, at least as many of
173/// them as opened it, and no info string, per CommonMark's closing rule.
174fn is_closing_fence(line: &str, fence: Fence) -> bool {
175    let t = line.trim();
176    t.len() >= fence.len && t.bytes().all(|b| b == fence.ch)
177}
178
179/// Whether `line` opens a markdown list item (`-`, `*`, `+`, or `1.` / `1)`).
180///
181/// Such a line begins a block, so metadata indented beneath it is still
182/// metadata. Without this, documenting a task as a bulleted list and hanging an
183/// `Env:` off one of the bullets would quietly stop working.
184fn opens_list_item(line: &str) -> bool {
185    let t = line.trim_start();
186    if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
187        return rest.starts_with(' ') || rest.is_empty();
188    }
189    let digits = t.trim_start_matches(|c: char| c.is_ascii_digit());
190    if digits.len() < t.len()
191        && let Some(rest) = digits.strip_prefix(['.', ')'])
192    {
193        return rest.starts_with(' ') || rest.is_empty();
194    }
195    false
196}
197
198/// The info-string language after the opening fence.
199fn info_string(line: &str, fence: Fence) -> String {
200    let t = line.trim_start();
201    t[fence.len.min(t.len())..]
202        .split_whitespace()
203        .next()
204        .unwrap_or("")
205        .to_string()
206}
207
208/// The heading text if `line` is an ATX heading (`#`..`######`), else `None`.
209fn heading(line: &str) -> Option<String> {
210    let t = line.trim_start();
211    if !t.starts_with('#') {
212        return None;
213    }
214    let after = t.trim_start_matches('#');
215    // Must have a space after the `#` run (a real ATX heading), and not be all #.
216    if after == t || !after.starts_with(' ') {
217        return None;
218    }
219    Some(after.trim().to_string())
220}
221
222/// Apply a body line: a recognized `Key: value` sets metadata (case-insensitive
223/// key, xc vocabulary); anything else is description. `Env:` before the first job
224/// Parse a `Requires:` value into requirements.
225///
226/// Comma-separated. A bare entry is a name with no arguments; an entry wrapped
227/// in parentheses is a name followed by whitespace-separated arguments, which is
228/// just's `(dist module)` shape. Splitting on commas first is what makes the
229/// parenthesised form unambiguous: whitespace can mean "next argument" precisely
230/// because it never had to mean "next dependency".
231/// Split a `Requires:` value into its entries, on commas that are not inside a
232/// parenthesised entry. `(deploy a, b)` is one entry with two arguments, not two
233/// entries, which is why this is not `value.split(',')`.
234fn split_entries(value: &str) -> Vec<&str> {
235    let mut out = Vec::new();
236    let mut depth = 0usize;
237    let mut start = 0usize;
238    for (i, c) in value.char_indices() {
239        match c {
240            '(' => depth += 1,
241            ')' => depth = depth.saturating_sub(1),
242            ',' if depth == 0 => {
243                out.push(&value[start..i]);
244                start = i + 1;
245            }
246            _ => {}
247        }
248    }
249    out.push(&value[start..]);
250    out
251}
252
253/// Split the inside of a parenthesised entry into a name and its arguments.
254///
255/// Whitespace or a comma separates. A comma already means "next thing" at the
256/// entry level, so `(deploy a, b)` reading as two arguments is what anyone will
257/// expect; a literal comma needs quoting.
258///
259/// Two things are held together across a separator: a `{{ ... }}` placeholder
260/// (one token however it is spaced, so `{{ module }}` stays a placeholder rather
261/// than becoming three arguments), and a double-quoted run (so an argument may
262/// contain a space or a comma at all).
263fn split_args(inner: &str) -> Vec<String> {
264    let mut out: Vec<String> = Vec::new();
265    let mut cur = String::new();
266    let mut has = false;
267    let mut rest = inner;
268
269    while let Some(c) = rest.chars().next() {
270        if c.is_whitespace() || c == ',' {
271            if has {
272                out.push(std::mem::take(&mut cur));
273                has = false;
274            }
275            rest = &rest[c.len_utf8()..];
276        } else if rest.starts_with("{{") {
277            // Verbatim through the closing braces, so `substitute` sees an
278            // intact placeholder later. Unterminated, it is just literal text.
279            let end = rest.find("}}").map_or(rest.len(), |i| i + 2);
280            cur.push_str(&rest[..end]);
281            has = true;
282            rest = &rest[end..];
283        } else if c == '"' {
284            let body = &rest[1..];
285            let end = body.find('"');
286            cur.push_str(end.map_or(body, |i| &body[..i]));
287            has = true;
288            rest = end.map_or("", |i| &body[i + 1..]);
289        } else {
290            cur.push(c);
291            has = true;
292            rest = &rest[c.len_utf8()..];
293        }
294    }
295    if has {
296        out.push(cur);
297    }
298    out
299}
300
301fn parse_requires(value: &str) -> Vec<Requirement> {
302    split_entries(value)
303        .into_iter()
304        .map(str::trim)
305        .filter(|s| !s.is_empty())
306        .map(|entry| {
307            match entry
308                .strip_prefix('(')
309                .and_then(|rest| rest.strip_suffix(')'))
310            {
311                Some(inner) => {
312                    let mut parts = split_args(inner).into_iter();
313                    Requirement {
314                        name: parts.next().unwrap_or_default(),
315                        args: parts.collect(),
316                    }
317                }
318                None => Requirement {
319                    name: entry.to_string(),
320                    args: Vec::new(),
321                },
322            }
323        })
324        .filter(|r: &Requirement| !r.name.is_empty())
325        .collect()
326}
327
328/// The metadata keys, and the aliases each accepts.
329const KEYS: &[&str] = &[
330    "env",
331    "environment",
332    "opts",
333    "options",
334    "args",
335    "arguments",
336    "requires",
337    "req",
338    "agent",
339];
340
341/// The key `word` was probably meant to be, if it is close enough to one.
342///
343/// Deliberately narrow: a singular/plural slip or one wrong character. Anything
344/// looser would start warning about ordinary prose, which is the thing this
345/// format has to live alongside.
346fn nearest_key(word: &str) -> Option<&'static str> {
347    KEYS.iter()
348        .copied()
349        .find(|k| {
350            // "arg" vs "args", "require" vs "requires", "opt" vs "opts".
351            k.strip_suffix('s') == Some(word)
352                || word.strip_suffix('s') == Some(*k)
353                || k.starts_with(word) && k.len() == word.len() + 1
354        })
355        .or_else(|| KEYS.iter().copied().find(|k| edit_distance_one(k, word)))
356}
357
358/// Whether two words differ by exactly one substitution. Cheap, and enough for
359/// the typos that actually happen in a metadata key.
360fn edit_distance_one(a: &str, b: &str) -> bool {
361    if a.len() != b.len() || a == b {
362        return false;
363    }
364    a.bytes().zip(b.bytes()).filter(|(x, y)| x != y).count() == 1
365}
366
367/// accumulates into the hoisted `file_env`.
368///
369/// `block_start` is whether this line begins a block: it follows the heading, a
370/// blank line, a fence, or another metadata line. Metadata is only recognized
371/// there, because otherwise a wrapped sentence decides the task's configuration.
372/// This was reachable:
373///
374/// ```markdown
375/// The reviewer decides whether to set
376/// Agent: allow
377/// on a task, which should be rare.
378/// ```
379///
380/// which read as prose to every human and as *opt this task in to agent
381/// execution* to the parser. Requiring a block boundary costs nothing, because
382/// every real task file already writes its metadata on its own lines, and it
383/// makes the security-relevant key unreachable from inside a paragraph.
384///
385/// Returns whether the line was consumed as metadata, which is how the caller
386/// keeps a run of `Args:` / `Requires:` lines together.
387fn apply_line(
388    line: &str,
389    job: Option<&mut Job>,
390    file_env: &mut Vec<(String, String)>,
391    file_opts: &mut Vec<String>,
392    warnings: &mut Vec<String>,
393    block_start: bool,
394) -> bool {
395    if let Some((key, value)) = split_key(line) {
396        // A recognized key mid-paragraph is prose. Say so rather than silently
397        // dropping it: if it really was meant as metadata, the author needs to
398        // know it did nothing.
399        if !block_start && KEYS.contains(&key.as_str()) {
400            warnings.push(format!(
401                "`{key}:` is inside a paragraph, so it was read as description, \
402                 not metadata; put it on its own line after a blank one"
403            ));
404            describe(line, job);
405            return false;
406        }
407        let value = value.trim();
408        match key.as_str() {
409            "env" | "environment" => {
410                let pairs = parse_env(value, warnings);
411                match job {
412                    Some(t) => t.env.extend(pairs),
413                    None => file_env.extend(pairs), // hoisted
414                }
415                return true;
416            }
417            "opts" | "options" => {
418                let Some(t) = job else {
419                    // Before the first heading: a file-level option, which is a
420                    // different vocabulary from a task's.
421                    for flag in value.split_whitespace() {
422                        if KNOWN_FILE_OPTS.contains(&flag) {
423                            file_opts.push(flag.to_string());
424                        } else {
425                            warnings.push(format!(
426                                "unknown file-level option {flag:?} in `Opts:` (known: {}); \
427                                 a task option belongs under a task heading",
428                                KNOWN_FILE_OPTS.join(", ")
429                            ));
430                        }
431                    }
432                    return true;
433                };
434                // Extend, not assign. Assigning meant a second `Opts:` line
435                // silently erased the first, and a second `Requires:` line
436                // silently dropped a dependency while still exiting 0.
437                t.opts.extend(value.split_whitespace().map(str::to_string));
438                for flag in &t.opts {
439                    if !KNOWN_OPTS.contains(&flag.as_str()) {
440                        let hint = if KNOWN_FILE_OPTS.contains(&flag.as_str()) {
441                            "; that one is file-level, so it goes before the first task heading"
442                        } else {
443                            ""
444                        };
445                        warnings.push(format!(
446                            "unknown option {flag:?} in `Opts:` (known: {}){hint}",
447                            KNOWN_OPTS.join(", ")
448                        ));
449                    }
450                }
451                return true;
452            }
453            "args" | "arguments" => {
454                if let Some(t) = job {
455                    let parsed = parse_args(value);
456                    // Caught here as well as refused at run time, so `mdtask`
457                    // with no arguments shows the problem before anyone tries
458                    // to run the task.
459                    for a in parsed.iter().filter(|a| !a.is_valid_name()) {
460                        warnings.push(format!(
461                            "task {:?}: argument name `{}` cannot be a shell variable. \
462                             `Args:` is whitespace-separated (just's syntax), so a comma \
463                             becomes part of the name: write `Args: a b`, not `Args: a, b`",
464                            t.name, a.name
465                        ));
466                    }
467                    t.args.extend(parsed);
468                }
469                return true;
470            }
471            "requires" | "req" => {
472                if let Some(t) = job {
473                    t.requires.extend(parse_requires(value));
474                }
475                return true;
476            }
477            "agent" => {
478                if let Some(t) = job {
479                    t.agent_allow = value.eq_ignore_ascii_case("allow");
480                }
481                return true;
482            }
483            other => {
484                // A near-miss of a real key is a typo, not prose. `Arg:`,
485                // `Require:` and `Opt:` all used to vanish into the description
486                // with no warning, so a declared dependency never ran and a
487                // declared argument never existed, silently and with exit 0.
488                //
489                // Only near-misses warn. An ordinary sentence starting "Note:"
490                // must stay description, or the format cannot coexist with the
491                // prose it is written in.
492                if let Some(meant) = nearest_key(other) {
493                    warnings.push(format!(
494                        "unknown metadata key {other:?}; did you mean {meant:?}? \
495                         (treating the line as description)"
496                    ));
497                }
498            }
499        }
500    }
501    describe(line, job);
502    false
503}
504
505/// Append a line to the job's description. Stray prose outside a job is dropped.
506fn describe(line: &str, job: Option<&mut Job>) {
507    if let Some(t) = job
508        && !line.trim().is_empty()
509    {
510        t.description.push_str(line.trim());
511        t.description.push('\n');
512    }
513}
514
515/// Split `Key: value`, returning the lowercased key if the line looks like one
516/// (a single-word key before the first colon). Leading indentation is allowed, so
517/// an `Env:` indented under a list still counts. This is safe because only *known*
518/// keys act (see `apply_line`), so ordinary prose with a colon stays description.
519fn split_key(line: &str) -> Option<(String, &str)> {
520    let colon = line.find(':')?;
521    let key = line[..colon].trim();
522    if key.is_empty() || key.contains(char::is_whitespace) {
523        return None;
524    }
525    Some((key.to_ascii_lowercase(), &line[colon + 1..]))
526}
527
528/// Parse an `Env:` value into `KEY=VALUE` pairs, comma-separated.
529///
530/// A value may be quoted, which is the only way to write one containing a
531/// comma. Without it, `Env: FLAGS=-a,-b` set `FLAGS` to `-a` and threw `-b`
532/// away without a word, because the fragment had no `=` and the old parser
533/// dropped anything that did not. Silently corrupting a value is worse than
534/// refusing it, so a fragment that cannot be a pair now warns.
535fn parse_env(value: &str, warnings: &mut Vec<String>) -> Vec<(String, String)> {
536    let mut out = Vec::new();
537    for entry in split_pairs(value) {
538        let entry = entry.trim();
539        if entry.is_empty() {
540            continue;
541        }
542        let Some((k, v)) = entry.split_once('=') else {
543            warnings.push(format!(
544                "`Env:` entry {entry:?} has no `=`, so it was ignored; entries are \
545                 comma-separated KEY=VALUE, and a value containing a comma has to be \
546                 quoted (KEY=\"a,b\")"
547            ));
548            continue;
549        };
550        let k = k.trim();
551        if k.is_empty() {
552            warnings.push(format!(
553                "`Env:` entry {entry:?} has an empty key, so it was ignored"
554            ));
555            continue;
556        }
557        out.push((k.to_string(), unquote(v).to_string()));
558    }
559    out
560}
561
562/// Split an `Env:` value on the commas that separate pairs, ignoring any inside
563/// a quoted run.
564fn split_pairs(value: &str) -> Vec<&str> {
565    let mut out = Vec::new();
566    let mut quote: Option<char> = None;
567    let mut start = 0usize;
568    for (i, c) in value.char_indices() {
569        match (quote, c) {
570            (None, '\'' | '"') => quote = Some(c),
571            (Some(q), c) if c == q => quote = None,
572            (None, ',') => {
573                out.push(&value[start..i]);
574                start = i + 1;
575            }
576            _ => {}
577        }
578    }
579    out.push(&value[start..]);
580    out
581}
582
583/// Parse an `Args:` value into declared [`Arg`]s (just's syntax): `name` is
584/// required, `*name` collects the rest (variadic), `name='default'` (or
585/// `name="default"`) is optional. Tokens are whitespace-separated, but a quoted
586/// default may itself contain spaces (`msg='hello world'`).
587fn parse_args(value: &str) -> Vec<Arg> {
588    tokenize_args(value)
589        .into_iter()
590        .filter_map(|tok| {
591            let (name, default) = match tok.split_once('=') {
592                Some((n, d)) => (n, Some(unquote(d).to_string())),
593                None => (tok.as_str(), None),
594            };
595            let (name, variadic) = match name.strip_prefix('*') {
596                Some(rest) => (rest, true),
597                None => (name, false),
598            };
599            let name = name.trim();
600            if name.is_empty() {
601                return None;
602            }
603            Some(Arg {
604                name: name.to_string(),
605                variadic,
606                default,
607            })
608        })
609        .collect()
610}
611
612/// Split an `Args:` value on whitespace, but keep a single- or double-quoted run
613/// (a default value) together so `msg='a b'` is one token.
614fn tokenize_args(value: &str) -> Vec<String> {
615    let mut out = Vec::new();
616    let mut cur = String::new();
617    let mut quote: Option<char> = None;
618    for c in value.chars() {
619        match quote {
620            Some(q) => {
621                cur.push(c);
622                if c == q {
623                    quote = None;
624                }
625            }
626            None if c == '\'' || c == '"' => {
627                cur.push(c);
628                quote = Some(c);
629            }
630            None if c.is_whitespace() => {
631                if !cur.is_empty() {
632                    out.push(std::mem::take(&mut cur));
633                }
634            }
635            None => cur.push(c),
636        }
637    }
638    if !cur.is_empty() {
639        out.push(cur);
640    }
641    out
642}
643
644/// Strip one matching pair of surrounding single or double quotes, if present.
645fn unquote(s: &str) -> &str {
646    let s = s.trim();
647    let b = s.as_bytes();
648    if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
649        &s[1..s.len() - 1]
650    } else {
651        s
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    /// CommonMark closes a fence only with a run of *at least as many* of the
660    /// same character. That is how a markdown block contains markdown blocks,
661    /// and it is what mdtask's own README needs: fence the sample with four
662    /// backticks and the inner three-backtick fences are content.
663    ///
664    /// Before this, every fence was three long, so the inner bare ``` closed the
665    /// outer block: the README parsed as a task named "mdtask" whose script was
666    /// half the sample and whose language was "`markdown".
667    #[test]
668    fn a_longer_fence_is_not_closed_by_a_shorter_one() {
669        let tf = parse("## t\n\n````markdown\n# inner\n\n```sh\necho nested\n```\n````\n");
670        assert_eq!(tf.jobs[0].lang, "markdown", "not \"`markdown\"");
671        let script = &tf.jobs[0].script;
672        assert!(
673            script.contains("```sh"),
674            "the inner fence is content: {script:?}"
675        );
676        assert!(script.contains("echo nested"), "{script:?}");
677        assert!(
678            !script.contains("````"),
679            "and the outer one is not: {script:?}"
680        );
681    }
682
683    #[test]
684    fn a_shorter_fence_is_still_closed_normally() {
685        let tf = parse("## t\n\n```sh\ntrue\n````\n");
686        assert_eq!(tf.jobs[0].script.trim(), "true", "a longer run closes it");
687        assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
688    }
689
690    #[test]
691    fn tildes_track_their_own_length() {
692        let tf = parse("## t\n\n~~~~sh\n~~~\nstill inside\n~~~~\n");
693        assert!(tf.jobs[0].script.contains("still inside"));
694    }
695
696    /// A backtick fence's info string may not contain a backtick, which is what
697    /// keeps a line of inline code from opening a block.
698    #[test]
699    fn inline_code_does_not_open_a_fence() {
700        assert!(opening_fence("```rust").is_some());
701        assert!(opening_fence("``` rust ```").is_none());
702        assert!(opening_fence("``").is_none(), "two is not a fence");
703        // Tildes have no such restriction in CommonMark.
704        assert!(opening_fence("~~~ a ~ b").is_some());
705    }
706
707    /// The silent corruption this replaced: `FLAGS=-a,-b` set `FLAGS` to `-a`
708    /// and threw `-b` away without a word, because the fragment had no `=` and
709    /// anything without one was dropped.
710    #[test]
711    fn an_env_value_with_a_comma_can_be_quoted() {
712        let tf = parse("## t\n\nEnv: FLAGS=\"-a,-b\", TIER=prod\n\n```sh\ntrue\n```\n");
713        assert_eq!(
714            tf.jobs[0].env,
715            vec![
716                ("FLAGS".into(), "-a,-b".into()),
717                ("TIER".into(), "prod".into())
718            ]
719        );
720        assert!(tf.warnings.is_empty(), "{:?}", tf.warnings);
721    }
722
723    #[test]
724    fn single_quotes_work_too() {
725        let tf = parse("## t\n\nEnv: FLAGS='-a,-b'\n\n```sh\ntrue\n```\n");
726        assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a,-b".into())]);
727    }
728
729    #[test]
730    fn an_env_fragment_that_cannot_be_a_pair_warns() {
731        let tf = parse("## t\n\nEnv: FLAGS=-a,-b\n\n```sh\ntrue\n```\n");
732        assert_eq!(tf.jobs[0].env, vec![("FLAGS".into(), "-a".into())]);
733        assert!(
734            tf.warnings.iter().any(|w| w.contains("has no `=`")),
735            "{:?}",
736            tf.warnings
737        );
738    }
739
740    #[test]
741    fn an_env_entry_with_an_empty_key_warns() {
742        let tf = parse("## t\n\nEnv: =orphan\n\n```sh\ntrue\n```\n");
743        assert!(tf.jobs[0].env.is_empty());
744        assert!(
745            tf.warnings.iter().any(|w| w.contains("empty key")),
746            "{:?}",
747            tf.warnings
748        );
749    }
750
751    /// An `=` inside a quoted value is part of the value, not a second pair.
752    #[test]
753    fn an_env_value_may_contain_an_equals_sign() {
754        let tf = parse("## t\n\nEnv: OPTS=\"a=1,b=2\"\n\n```sh\ntrue\n```\n");
755        assert_eq!(tf.jobs[0].env, vec![("OPTS".into(), "a=1,b=2".into())]);
756    }
757
758    fn req_names(reqs: &[Requirement]) -> Vec<&str> {
759        reqs.iter().map(|r| r.name.as_str()).collect()
760    }
761
762    #[test]
763    fn parses_named_jobs_with_interpreter() {
764        let tf =
765            parse("## build\n\n```sh\ncargo build\n```\n\n## check\n\n```zsh\nprint hi\n```\n");
766        assert_eq!(tf.jobs.len(), 2);
767        assert_eq!(tf.jobs[0].name, "build");
768        assert_eq!(tf.jobs[0].lang, "sh");
769        assert_eq!(tf.jobs[0].script.trim(), "cargo build");
770        assert_eq!(tf.jobs[1].lang, "zsh");
771    }
772
773    #[test]
774    fn metadata_keys_are_case_insensitive() {
775        let tf = parse(
776            "## deploy\n\nOPTS: inherit-cwd\nEnv: REGION=us, TIER=prod\nArgs: target\nRequires: build, test\nAgent: allow\n\n```sh\necho go\n```\n",
777        );
778        let t = &tf.jobs[0];
779        assert_eq!(t.opts, vec!["inherit-cwd"]);
780        assert!(t.inherits_cwd());
781        assert_eq!(
782            t.env,
783            vec![
784                ("REGION".into(), "us".into()),
785                ("TIER".into(), "prod".into())
786            ]
787        );
788        assert_eq!(
789            t.args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
790            ["target"]
791        );
792        assert_eq!(req_names(&t.requires), ["build", "test"]);
793        assert!(t.agent_allow);
794    }
795
796    #[test]
797    fn agent_gate_is_off_by_default() {
798        let tf = parse("## secret\n\n```sh\nrm -rf /\n```\n");
799        assert!(!tf.jobs[0].agent_allow);
800    }
801
802    #[test]
803    fn top_level_env_is_hoisted() {
804        let tf = parse("# Tasks\n\nEnv: SHARED=1\n\n## a\n\n```sh\ntrue\n```\n");
805        assert_eq!(tf.env, vec![("SHARED".into(), "1".into())]);
806    }
807
808    #[test]
809    fn fence_content_is_not_parsed_as_structure() {
810        // A `## heading` and a `Key:` line inside a fence stay in the script.
811        let tf = parse("## a\n\n```sh\n## not a task\nEnv: NOPE=1\n```\n");
812        assert_eq!(tf.jobs.len(), 1);
813        assert!(tf.jobs[0].script.contains("## not a task"));
814        assert!(tf.jobs[0].env.is_empty());
815    }
816
817    #[test]
818    fn an_unknown_opt_warns_but_is_ignored() {
819        let tf = parse("## t\n\nOpts: inherit-cwd bogus\n\n```sh\ntrue\n```\n");
820        assert_eq!(tf.jobs[0].opts, vec!["inherit-cwd", "bogus"]);
821        assert!(tf.jobs[0].inherits_cwd()); // the known flag still applies
822        assert!(tf.warnings.iter().any(|w| w.contains("bogus")));
823    }
824
825    // ---- parameterized dependencies ---------------------------------------
826
827    #[test]
828    fn a_bare_requirement_carries_no_arguments() {
829        let reqs = parse_requires("build, test");
830        assert_eq!(req_names(&reqs), ["build", "test"]);
831        assert!(reqs.iter().all(|r| r.args.is_empty()));
832    }
833
834    #[test]
835    fn a_parenthesised_requirement_carries_its_arguments() {
836        let reqs = parse_requires("lint, (dist bonus-die)");
837        assert_eq!(req_names(&reqs), ["lint", "dist"]);
838        assert!(reqs[0].args.is_empty(), "the bare one is untouched");
839        assert_eq!(reqs[1].args, ["bonus-die"]);
840    }
841
842    /// `{{ module }}` is conventionally written with spaces, and a plain
843    /// whitespace split turns it into three arguments. It must survive whole or
844    /// the syntax is unusable in the form everyone will write it in.
845    #[test]
846    fn a_spaced_placeholder_stays_one_argument() {
847        assert_eq!(
848            parse_requires("(dist {{ module }})")[0].args,
849            ["{{ module }}"]
850        );
851        assert_eq!(parse_requires("(dist {{module}})")[0].args, ["{{module}}"]);
852        // And a placeholder is a token, not the whole argument.
853        assert_eq!(
854            parse_requires("(dist {{ module }}-docs)")[0].args,
855            ["{{ module }}-docs"]
856        );
857    }
858
859    /// Entries are comma-separated and arguments are space-separated, so a comma
860    /// inside the parentheses would otherwise cut an entry in half and leave a
861    /// requirement named `b)`.
862    #[test]
863    fn a_comma_inside_parentheses_does_not_split_the_entry() {
864        let reqs = parse_requires("(deploy a, b), lint");
865        assert_eq!(req_names(&reqs), ["deploy", "lint"]);
866        assert_eq!(reqs[0].args, ["a", "b"]);
867    }
868
869    #[test]
870    fn a_quoted_argument_may_contain_a_space() {
871        let reqs = parse_requires(r#"(deploy "the droplet, west" now)"#);
872        assert_eq!(reqs[0].args, ["the droplet, west", "now"]);
873    }
874
875    /// An unterminated placeholder or quote is text, not a parse failure: this
876    /// runs over hand-written markdown, and refusing to plan is worse than
877    /// passing through what was actually typed.
878    #[test]
879    fn an_unterminated_placeholder_or_quote_is_taken_literally() {
880        assert_eq!(parse_requires("(dist {{ module)")[0].args, ["{{ module"]);
881        assert_eq!(parse_requires(r#"(dist "oops)"#)[0].args, ["oops"]);
882    }
883
884    #[test]
885    fn crlf_scripts_are_normalized() {
886        let tf = parse("## t\r\n\r\n```sh\r\necho foo\r\necho bar\r\n```\r\n");
887        assert_eq!(tf.jobs[0].script, "echo foo\necho bar\n");
888        assert!(!tf.jobs[0].script.contains('\r'));
889    }
890
891    #[test]
892    fn an_unterminated_fence_warns_but_keeps_the_job() {
893        let tf = parse("## a\n\n```sh\necho hi\n"); // no closing fence
894        assert_eq!(tf.jobs.len(), 1);
895        assert_eq!(tf.jobs[0].script.trim(), "echo hi");
896        assert!(tf.warnings.iter().any(|w| w.contains("unterminated")));
897    }
898
899    #[test]
900    fn a_stray_fence_open_does_not_close_an_unterminated_block() {
901        // ```sh has an info string, so it opens rather than closes; only a bare
902        // ``` closes. (The trailing block here is what closes it.)
903        let tf = parse("## a\n\n```sh\none\n```sh\ntwo\n```\n");
904        assert!(tf.jobs[0].script.contains("one"));
905        assert!(tf.jobs[0].script.contains("```sh\ntwo"));
906    }
907
908    /// The one that matters. A sentence can be wrapped so that a line inside it
909    /// reads as a metadata key, which let a paragraph opt its own task in to
910    /// agent execution while reading as ordinary prose to every human reviewing
911    /// the file. Metadata has to begin a block.
912    #[test]
913    fn metadata_inside_a_paragraph_does_not_configure_the_task() {
914        let tf = parse(
915            "## t\n\nThe reviewer decides whether to set\nAgent: allow\non a task.\n\n```sh\ntrue\n```\n",
916        );
917        assert!(!tf.jobs[0].agent_allow, "prose must not open the gate");
918        assert!(
919            tf.jobs[0].description.contains("Agent: allow"),
920            "it is description, and stays visible as such"
921        );
922    }
923
924    /// Silently ignoring it would be its own trap: an author who did mean it
925    /// needs to hear that it did nothing.
926    #[test]
927    fn metadata_inside_a_paragraph_warns() {
928        let tf = parse("## t\n\nRun this after\nRequires: build\n\n```sh\ntrue\n```\n");
929        assert!(tf.jobs[0].requires.is_empty());
930        assert!(
931            tf.warnings.iter().any(|w| w.contains("inside a paragraph")),
932            "warnings: {:?}",
933            tf.warnings
934        );
935    }
936
937    /// Metadata after prose is how nearly every real task file is written: a
938    /// paragraph of description, a blank line, then `Args:`. The rule is about
939    /// paragraph *interiors*, and must not break that.
940    #[test]
941    fn metadata_after_a_blank_line_still_works() {
942        let tf = parse(
943            "## t\n\nSet a module's version.\n\nArgs: module version\nRequires: lint\nAgent: allow\n\n```sh\ntrue\n```\n",
944        );
945        let j = &tf.jobs[0];
946        assert_eq!(j.args.len(), 2, "after a blank line");
947        assert_eq!(req_names(&j.requires), ["lint"], "and a run stays together");
948        assert!(j.agent_allow);
949        assert!(tf.warnings.is_empty(), "warnings: {:?}", tf.warnings);
950    }
951
952    #[test]
953    fn metadata_directly_under_the_heading_still_works() {
954        let tf = parse("## t\nArgs: one\n\n```sh\ntrue\n```\n");
955        assert_eq!(tf.jobs[0].args.len(), 1);
956    }
957
958    #[test]
959    fn opens_list_item_recognizes_the_usual_markers() {
960        for good in ["- a", "* a", "+ a", "1. a", "12) a", "  - indented"] {
961            assert!(opens_list_item(good), "{good:?}");
962        }
963        for bad in ["-not a bullet", "a - b", "1.5 is a number", "", "text"] {
964            assert!(!opens_list_item(bad), "{bad:?}");
965        }
966    }
967
968    /// A description keeps its paragraph breaks, so a consumer can tell where
969    /// the opening thought ends. Dropping blanks left one undifferentiated run
970    /// of lines, and the only "summary" available was a hard-wrap fragment.
971    #[test]
972    fn a_description_keeps_its_paragraph_breaks() {
973        let tf = parse("## t\n\nFirst thought,\nwrapped.\n\nSecond thought.\n\n```sh\ntrue\n```\n");
974        let d = &tf.jobs[0].description;
975        let paras: Vec<&str> = d.split("\n\n").filter(|p| !p.trim().is_empty()).collect();
976        assert_eq!(paras.len(), 2, "description was {d:?}");
977        assert_eq!(paras[0].trim(), "First thought,\nwrapped.");
978    }
979
980    #[test]
981    fn indented_metadata_is_recognized() {
982        let tf = parse("## a\n\n- steps:\n  Env: KEY=val\n\n```sh\ntrue\n```\n");
983        assert_eq!(tf.jobs[0].env, vec![("KEY".into(), "val".into())]);
984    }
985
986    /// An unrecognized language is still a task, run as `sh`, and warned about.
987    /// Forgiving is deliberate: `shell-session` and `bash5` should work.
988    /// Repeated metadata accumulates. Assigning meant the second line silently
989    /// erased the first, so a declared dependency never ran and the task still
990    /// exited 0.
991    #[test]
992    fn repeated_metadata_lines_accumulate() {
993        let tf = parse("## t\n\nRequires: alpha\nRequires: beta\n\n```sh\ntrue\n```\n");
994        assert_eq!(req_names(&tf.jobs[0].requires), ["alpha", "beta"]);
995
996        let tf = parse("## t\n\nArgs: a\nArgs: b\n\n```sh\ntrue\n```\n");
997        let names: Vec<_> = tf.jobs[0].args.iter().map(|a| a.name.as_str()).collect();
998        assert_eq!(names, vec!["a", "b"]);
999    }
1000
1001    /// A near-miss of a real key is a typo, and used to vanish into the
1002    /// description with no warning at all.
1003    #[test]
1004    fn a_misspelled_metadata_key_warns() {
1005        for (typo, meant) in [("Require", "requires"), ("Arg", "args"), ("Opt", "opts")] {
1006            let src = format!("## t\n\n{typo}: x\n\n```sh\ntrue\n```\n");
1007            let tf = parse(&src);
1008            assert!(
1009                tf.warnings.iter().any(|w| w.contains(meant)),
1010                "{typo}: should suggest {meant}, warnings were {:?}",
1011                tf.warnings
1012            );
1013        }
1014    }
1015
1016    /// ...but ordinary prose that happens to start with a word and a colon must
1017    /// stay prose, or the format cannot live in the documentation it claims to.
1018    #[test]
1019    fn ordinary_prose_is_not_mistaken_for_metadata() {
1020        let tf = parse(
1021            "## t\n\nNote: this is a sentence.\nWarning: so is this.\nSee: the docs.\n\n```sh\ntrue\n```\n",
1022        );
1023        assert!(
1024            tf.warnings.is_empty(),
1025            "prose should not warn, got {:?}",
1026            tf.warnings
1027        );
1028        assert!(tf.jobs[0].description.contains("Note: this is a sentence."));
1029    }
1030
1031    #[test]
1032    fn an_unknown_language_is_still_a_task_and_warns() {
1033        let tf = parse("## a\n\n```shell-session\ntrue\n```\n");
1034        assert_eq!(tf.jobs.len(), 1);
1035        assert!(tf.warnings.iter().any(|w| w.contains("shell-session")));
1036        assert!(
1037            tf.warnings
1038                .iter()
1039                .any(|w| w.contains("running as a strict sh"))
1040        );
1041    }
1042
1043    #[test]
1044    fn duplicate_names_warn_and_the_first_wins() {
1045        let tf = parse("## a\n\n```sh\necho one\n```\n\n## a\n\n```sh\necho two\n```\n");
1046        assert_eq!(tf.jobs.len(), 2);
1047        assert!(tf.jobs[0].script.contains("one"));
1048        assert!(tf.warnings.iter().any(|w| w.contains("duplicate")));
1049    }
1050
1051    #[test]
1052    fn a_task_option_at_file_level_says_where_it_belongs() {
1053        let tf = parse("Opts: inherit-cwd\n\n## t\n\n```sh\ntrue\n```\n");
1054        assert!(!tf.includes_parent());
1055        assert!(
1056            tf.warnings
1057                .iter()
1058                .any(|w| w.contains("task option belongs")),
1059            "warnings: {:?}",
1060            tf.warnings
1061        );
1062    }
1063
1064    #[test]
1065    fn a_file_option_under_a_task_says_where_it_belongs() {
1066        let tf = parse("## t\n\nOpts: include-parent\n\n```sh\ntrue\n```\n");
1067        assert!(
1068            tf.warnings.iter().any(|w| w.contains("file-level")),
1069            "warnings: {:?}",
1070            tf.warnings
1071        );
1072    }
1073
1074    #[test]
1075    fn no_strict_is_a_known_opt_and_warns_no_one() {
1076        let tf = parse("## t\n\nOpts: no-strict\n\n```sh\ntrue\n```\n");
1077        assert!(
1078            tf.warnings().is_empty(),
1079            "no-strict must be recognized: {:?}",
1080            tf.warnings()
1081        );
1082    }
1083}
1084
1085#[cfg(test)]
1086mod invalid_arg_names {
1087    use super::*;
1088
1089    fn file(args_line: &str) -> TaskFile {
1090        parse(&format!(
1091            "## build\n\nDo a thing.\n\nArgs: {args_line}\n\n```sh\necho \"$slug\"\n```\n"
1092        ))
1093    }
1094
1095    /// The slip this exists for. `Args:` follows just's syntax and splits on
1096    /// whitespace, so the comma became part of the name and the task died in
1097    /// bash as `slug: unbound variable`, naming the spelling that was correct.
1098    #[test]
1099    fn a_comma_separated_list_warns_and_says_why() {
1100        let tf = file("slug, repo");
1101        let warning = tf
1102            .warnings()
1103            .iter()
1104            .find(|w| w.contains("slug,"))
1105            .unwrap_or_else(|| panic!("no warning about `slug,` in {:?}", tf.warnings()));
1106        assert!(warning.contains("whitespace-separated"), "{warning}");
1107        assert!(warning.contains("Args: a b"), "{warning}");
1108    }
1109
1110    /// Only the malformed one. The second name is fine and warning about it
1111    /// would bury the one that matters.
1112    #[test]
1113    fn only_the_offending_name_is_reported() {
1114        let tf = file("slug, repo");
1115        assert_eq!(tf.warnings().len(), 1, "{:?}", tf.warnings());
1116    }
1117
1118    #[test]
1119    fn a_well_formed_declaration_warns_about_nothing() {
1120        assert!(file("slug repo").warnings().is_empty());
1121        assert!(file("slug *rest").warnings().is_empty());
1122        assert!(file("slug msg='hello world'").warnings().is_empty());
1123        assert!(file("_private").warnings().is_empty());
1124    }
1125
1126    #[test]
1127    fn validity_is_the_shell_identifier_rule() {
1128        let valid = |name: &str| {
1129            Arg {
1130                name: name.into(),
1131                variadic: false,
1132                default: None,
1133            }
1134            .is_valid_name()
1135        };
1136        assert!(valid("slug"));
1137        assert!(valid("_slug"));
1138        assert!(valid("slug2"));
1139        assert!(!valid("slug,"));
1140        assert!(!valid("2slug"), "a leading digit is not an identifier");
1141        assert!(!valid("my-slug"), "a hyphen is not an identifier");
1142        assert!(!valid(""));
1143    }
1144
1145    /// The variadic and default markers are stripped before the name is judged,
1146    /// so a valid one must not be reported as broken.
1147    #[test]
1148    fn markers_are_not_part_of_the_name() {
1149        assert!(file("*rest").warnings().is_empty());
1150        assert!(file("name='a, b'").warnings().is_empty());
1151    }
1152}