Skip to main content

waypoint_core/
directive.rs

1//! Parse `-- waypoint:*` comment directives from SQL file headers.
2//!
3//! Directives appear as SQL comments at the top of migration files:
4//! ```sql
5//! -- waypoint:env dev,staging
6//! -- waypoint:depends V3,V5
7//! CREATE TABLE ...
8//! ```
9
10/// Parsed directives from a migration file header.
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct MigrationDirectives {
13    /// Dependencies: `-- waypoint:depends V3,V5` (V prefix is stripped)
14    pub depends: Vec<String>,
15    /// Environment tags: `-- waypoint:env dev,staging`
16    pub env: Vec<String>,
17    /// Preconditions: `-- waypoint:require table_exists("users")`
18    pub require: Vec<String>,
19    /// Postconditions: `-- waypoint:ensure column_exists("users", "email")`
20    pub ensure: Vec<String>,
21    /// Safety override: `-- waypoint:safety-override` bypasses DANGER blocks
22    pub safety_override: bool,
23}
24
25/// Scope of an inline lint suppression.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
27pub enum LintIgnoreScope {
28    /// `-- waypoint:lint-ignore` — applies to the next statement only.
29    NextStatement,
30    /// `-- waypoint:lint-ignore-file` — applies to the whole file.
31    File,
32}
33
34impl std::fmt::Display for LintIgnoreScope {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            LintIgnoreScope::NextStatement => write!(f, "statement"),
38            LintIgnoreScope::File => write!(f, "file"),
39        }
40    }
41}
42
43/// An inline `-- waypoint:lint-ignore[-file]` directive.
44///
45/// ```sql
46/// -- waypoint:lint-ignore E001 reason="backfilled by the ceremony writer"
47/// ALTER TABLE t ADD COLUMN c int NOT NULL;
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LintIgnoreDirective {
51    /// Whether this suppresses the next statement or the whole file.
52    pub scope: LintIgnoreScope,
53    /// Rule IDs named by the directive, uppercased. Never empty for a valid
54    /// directive; an empty list means the directive named no rules.
55    pub rules: Vec<String>,
56    /// The mandatory `reason=...` value, if one was supplied.
57    pub reason: Option<String>,
58    /// 1-based line number of the directive.
59    pub line: usize,
60    /// Byte offset of the start of the directive's line.
61    pub offset: usize,
62}
63
64/// Parse every `-- waypoint:lint-ignore[-file]` directive in a migration file.
65///
66/// Unlike the header directives, these may appear anywhere in the file, but
67/// only on lines that contain nothing but the comment — a trailing comment on
68/// a line of SQL is ignored, because its scope would be ambiguous.
69pub fn parse_lint_ignores(sql: &str) -> Vec<LintIgnoreDirective> {
70    let mut out = Vec::new();
71    let mut offset = 0usize;
72
73    // split('\n') rather than lines() so byte offsets stay exact on CRLF input.
74    for (idx, line) in sql.split('\n').enumerate() {
75        let line_offset = offset;
76        offset += line.len() + 1;
77
78        let trimmed = line.trim();
79        let Some(body) = trimmed.strip_prefix("--") else {
80            continue;
81        };
82        let body = body.trim();
83
84        let (scope, rest) =
85            if let Some(rest) = strip_directive_prefix(body, "waypoint:lint-ignore-file") {
86                (LintIgnoreScope::File, rest)
87            } else if let Some(rest) = strip_directive_prefix(body, "waypoint:lint-ignore") {
88                (LintIgnoreScope::NextStatement, rest)
89            } else {
90                continue;
91            };
92
93        let (rules_part, reason) = split_reason(rest);
94        let rules = rules_part
95            .split([',', ' ', '\t'])
96            .map(|r| r.trim())
97            .filter(|r| !r.is_empty())
98            .map(|r| r.to_uppercase())
99            .collect();
100
101        out.push(LintIgnoreDirective {
102            scope,
103            rules,
104            reason,
105            line: idx + 1,
106            offset: line_offset,
107        });
108    }
109
110    out
111}
112
113/// Split a directive tail into its rule list and its `reason=` value.
114///
115/// The reason runs to the end of the line and may be quoted with `"` or `'`.
116fn split_reason(rest: &str) -> (&str, Option<String>) {
117    let lower = rest.to_lowercase();
118    let Some(pos) = lower.find("reason") else {
119        return (rest, None);
120    };
121    // Require `reason` to be a standalone word followed by `=` or `:`.
122    let after = rest[pos + "reason".len()..].trim_start();
123    let Some(value) = after.strip_prefix('=').or_else(|| after.strip_prefix(':')) else {
124        return (rest, None);
125    };
126    let value = value.trim();
127    let value = value
128        .strip_prefix('"')
129        .and_then(|v| v.strip_suffix('"'))
130        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
131        .unwrap_or(value)
132        .trim();
133
134    let reason = if value.is_empty() {
135        None
136    } else {
137        Some(value.to_string())
138    };
139    (&rest[..pos], reason)
140}
141
142/// Strip a directive prefix, ensuring the prefix is followed by whitespace or end of string.
143/// This prevents prefix collisions like "waypoint:env" matching "waypoint:environment".
144fn strip_directive_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
145    if let Some(rest) = line.strip_prefix(prefix) {
146        if rest.is_empty() || rest.starts_with(char::is_whitespace) {
147            Some(rest.trim())
148        } else {
149            None
150        }
151    } else {
152        None
153    }
154}
155
156/// Parse `-- waypoint:*` directives from SQL content.
157///
158/// Only parses comment lines (`--`) at the top of the file.
159/// Stops at the first non-empty, non-comment line.
160pub fn parse_directives(sql: &str) -> MigrationDirectives {
161    let mut directives = MigrationDirectives::default();
162
163    for line in sql.lines() {
164        let trimmed = line.trim();
165
166        // Skip empty lines at the top
167        if trimmed.is_empty() {
168            continue;
169        }
170
171        // Only process SQL comment lines
172        if !trimmed.starts_with("--") {
173            break;
174        }
175
176        let comment_body = trimmed.strip_prefix("--").unwrap().trim();
177
178        if let Some(value) = strip_directive_prefix(comment_body, "waypoint:depends") {
179            for item in value.split(',') {
180                let item = item.trim();
181                if !item.is_empty() {
182                    // Strip optional V prefix
183                    let version = item.strip_prefix('V').unwrap_or(item);
184                    directives.depends.push(version.to_string());
185                }
186            }
187        } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:env") {
188            for item in value.split(',') {
189                let item = item.trim();
190                if !item.is_empty() {
191                    directives.env.push(item.to_string());
192                }
193            }
194        } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:require") {
195            if !value.is_empty() {
196                directives.require.push(value.to_string());
197            }
198        } else if let Some(value) = strip_directive_prefix(comment_body, "waypoint:ensure") {
199            if !value.is_empty() {
200                directives.ensure.push(value.to_string());
201            }
202        } else if comment_body.trim() == "waypoint:safety-override" {
203            directives.safety_override = true;
204        } else if let Some(unknown) = unrecognised_directive(comment_body) {
205            // A misspelled directive used to be indistinguishable from an
206            // ordinary comment. `-- waypoint:requires table_exists("x")` — the
207            // plural is an easy slip — silently dropped the precondition, and
208            // the migration then ran without the guard the author wrote.
209            log::warn!(
210                "Unrecognised directive '-- waypoint:{}' — this line is being treated as an \
211                 ordinary comment and has no effect. Known directives: depends, env, require, \
212                 ensure, safety-override, lint-ignore, lint-ignore-file.",
213                unknown
214            );
215        }
216    }
217
218    directives
219}
220
221/// The directive name in `comment_body`, if it looks like a `waypoint:`
222/// directive but is not one we know.
223///
224/// Returns `None` for ordinary comments and for the `lint-ignore` family, which
225/// [`parse_lint_ignores`] handles in its own pass over the file.
226fn unrecognised_directive(comment_body: &str) -> Option<&str> {
227    let name = comment_body.strip_prefix("waypoint:")?;
228    let head = name
229        .split_whitespace()
230        .next()
231        .unwrap_or(name)
232        .trim_end_matches(':');
233    if head.is_empty() || matches!(head, "lint-ignore" | "lint-ignore-file") {
234        return None;
235    }
236    Some(head)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_parse_env_directive() {
245        let sql = "-- waypoint:env dev,staging\nCREATE TABLE foo();";
246        let d = parse_directives(sql);
247        assert_eq!(d.env, vec!["dev", "staging"]);
248        assert!(d.depends.is_empty());
249    }
250
251    #[test]
252    fn test_parse_depends_directive() {
253        let sql = "-- waypoint:depends V3,V5\nCREATE TABLE foo();";
254        let d = parse_directives(sql);
255        assert_eq!(d.depends, vec!["3", "5"]);
256        assert!(d.env.is_empty());
257    }
258
259    #[test]
260    fn test_parse_depends_without_v_prefix() {
261        let sql = "-- waypoint:depends 3,5\nCREATE TABLE foo();";
262        let d = parse_directives(sql);
263        assert_eq!(d.depends, vec!["3", "5"]);
264    }
265
266    #[test]
267    fn test_parse_multiple_directives() {
268        let sql = "-- waypoint:env dev\n-- waypoint:depends V1,V2\nCREATE TABLE foo();";
269        let d = parse_directives(sql);
270        assert_eq!(d.env, vec!["dev"]);
271        assert_eq!(d.depends, vec!["1", "2"]);
272    }
273
274    #[test]
275    fn test_stops_at_non_comment_line() {
276        let sql = "-- waypoint:env dev\nCREATE TABLE foo();\n-- waypoint:env prod\n";
277        let d = parse_directives(sql);
278        assert_eq!(d.env, vec!["dev"]);
279    }
280
281    #[test]
282    fn test_empty_sql() {
283        let d = parse_directives("");
284        assert!(d.env.is_empty());
285        assert!(d.depends.is_empty());
286    }
287
288    #[test]
289    fn test_no_directives() {
290        let sql = "-- Regular comment\nCREATE TABLE foo();";
291        let d = parse_directives(sql);
292        assert!(d.env.is_empty());
293        assert!(d.depends.is_empty());
294    }
295
296    #[test]
297    fn test_skips_leading_blank_lines() {
298        let sql = "\n\n-- waypoint:env prod\nCREATE TABLE foo();";
299        let d = parse_directives(sql);
300        assert_eq!(d.env, vec!["prod"]);
301    }
302
303    #[test]
304    fn test_whitespace_in_values() {
305        let sql = "-- waypoint:env  dev , staging , prod \nCREATE TABLE foo();";
306        let d = parse_directives(sql);
307        assert_eq!(d.env, vec!["dev", "staging", "prod"]);
308    }
309
310    #[test]
311    fn test_no_env_runs_everywhere() {
312        let d = MigrationDirectives::default();
313        assert!(d.env.is_empty());
314    }
315
316    #[test]
317    fn test_parse_require_directive() {
318        let sql = "-- waypoint:require table_exists(\"users\")\nCREATE TABLE foo();";
319        let d = parse_directives(sql);
320        assert_eq!(d.require, vec!["table_exists(\"users\")"]);
321    }
322
323    #[test]
324    fn test_parse_ensure_directive() {
325        let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
326        let d = parse_directives(sql);
327        assert_eq!(d.ensure, vec!["column_exists(\"users\", \"email\")"]);
328    }
329
330    #[test]
331    fn test_parse_multiple_guards() {
332        let sql = "-- waypoint:require table_exists(\"users\")\n-- waypoint:require NOT column_exists(\"users\", \"email\")\n-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
333        let d = parse_directives(sql);
334        assert_eq!(d.require.len(), 2);
335        assert_eq!(d.ensure.len(), 1);
336    }
337
338    #[test]
339    fn test_parse_lint_ignore_next_statement() {
340        let sql = "-- waypoint:lint-ignore E001 reason=\"empty table at deploy time\"\nALTER TABLE t ADD COLUMN a int NOT NULL;";
341        let d = parse_lint_ignores(sql);
342        assert_eq!(d.len(), 1);
343        assert_eq!(d[0].scope, LintIgnoreScope::NextStatement);
344        assert_eq!(d[0].rules, vec!["E001"]);
345        assert_eq!(d[0].reason.as_deref(), Some("empty table at deploy time"));
346        assert_eq!(d[0].line, 1);
347        assert_eq!(d[0].offset, 0);
348    }
349
350    #[test]
351    fn test_parse_lint_ignore_file_scope_and_multiple_rules() {
352        let sql = "-- header\n-- waypoint:lint-ignore-file E001,W004 reason=legacy migration\nDROP TABLE t;";
353        let d = parse_lint_ignores(sql);
354        assert_eq!(d.len(), 1);
355        assert_eq!(d[0].scope, LintIgnoreScope::File);
356        assert_eq!(d[0].rules, vec!["E001", "W004"]);
357        assert_eq!(d[0].reason.as_deref(), Some("legacy migration"));
358        assert_eq!(d[0].line, 2);
359    }
360
361    #[test]
362    fn test_parse_lint_ignore_without_reason() {
363        let d = parse_lint_ignores("-- waypoint:lint-ignore E001\nSELECT 1;");
364        assert_eq!(d.len(), 1);
365        assert!(d[0].reason.is_none());
366        assert_eq!(d[0].rules, vec!["E001"]);
367    }
368
369    #[test]
370    fn test_parse_lint_ignore_without_rules() {
371        let d = parse_lint_ignores("-- waypoint:lint-ignore reason=because\nSELECT 1;");
372        assert_eq!(d.len(), 1);
373        assert!(d[0].rules.is_empty());
374        assert_eq!(d[0].reason.as_deref(), Some("because"));
375    }
376
377    #[test]
378    fn test_parse_lint_ignore_offsets_are_exact() {
379        let sql = "SELECT 1;\n-- waypoint:lint-ignore E001 reason=x\nSELECT 2;";
380        let d = parse_lint_ignores(sql);
381        assert_eq!(d[0].line, 2);
382        assert_eq!(&sql[d[0].offset..d[0].offset + 2], "--");
383    }
384
385    #[test]
386    fn test_trailing_comment_is_not_a_directive() {
387        // Scope would be ambiguous, so only comment-only lines count.
388        let d = parse_lint_ignores("SELECT 1; -- waypoint:lint-ignore E001 reason=x\n");
389        assert!(d.is_empty());
390    }
391
392    #[test]
393    fn test_lint_ignore_prefix_does_not_collide() {
394        let d = parse_lint_ignores("-- waypoint:lint-ignore-file E001 reason=x\n");
395        assert_eq!(d[0].scope, LintIgnoreScope::File);
396        assert_eq!(d[0].rules, vec!["E001"]);
397    }
398
399    #[test]
400    fn test_parse_safety_override() {
401        let sql = "-- waypoint:safety-override\nALTER TABLE large_table ADD COLUMN foo TEXT;";
402        let d = parse_directives(sql);
403        assert!(d.safety_override);
404    }
405
406    #[test]
407    fn test_safety_override_default_false() {
408        let sql = "CREATE TABLE foo();";
409        let d = parse_directives(sql);
410        assert!(!d.safety_override);
411    }
412
413    #[test]
414    fn test_env_prefix_does_not_match_ensure() {
415        let sql = "-- waypoint:ensure column_exists(\"users\", \"email\")\nALTER TABLE users ADD COLUMN email TEXT;";
416        let d = parse_directives(sql);
417        // Should be parsed as ensure, not env
418        assert!(d.env.is_empty());
419        assert_eq!(d.ensure.len(), 1);
420    }
421
422    #[test]
423    fn test_directive_prefix_boundary() {
424        // "waypoint:environment" should NOT match "waypoint:env"
425        let sql = "-- waypoint:environment prod\nCREATE TABLE foo();";
426        let d = parse_directives(sql);
427        // Should NOT be parsed as env directive since "waypoint:environment" != "waypoint:env"
428        assert!(d.env.is_empty());
429    }
430
431    #[test]
432    fn test_parse_empty_depends() {
433        let sql = "-- waypoint:depends\nCREATE TABLE foo();";
434        let d = parse_directives(sql);
435        assert!(d.depends.is_empty());
436    }
437
438    #[test]
439    fn test_parse_empty_env() {
440        let sql = "-- waypoint:env\nCREATE TABLE foo();";
441        let d = parse_directives(sql);
442        assert!(d.env.is_empty());
443    }
444
445    #[test]
446    fn test_parse_require_with_special_chars() {
447        let sql = "-- waypoint:require table_exists(\"my-table\")\nCREATE TABLE foo();";
448        let d = parse_directives(sql);
449        assert_eq!(d.require, vec!["table_exists(\"my-table\")"]);
450    }
451
452    #[test]
453    fn test_unrecognised_directive_detects_typos_but_not_ordinary_comments() {
454        // Typos in directive names used to be silently indistinguishable from
455        // a plain comment, so a mistyped `require` dropped the precondition.
456        assert_eq!(
457            unrecognised_directive("waypoint:requires foo()"),
458            Some("requires")
459        );
460        assert_eq!(
461            unrecognised_directive("waypoint:saftey-override"),
462            Some("saftey-override")
463        );
464        assert_eq!(
465            unrecognised_directive("waypoint:ensures x"),
466            Some("ensures")
467        );
468
469        // Known directives and ordinary comments are not flagged.
470        assert_eq!(
471            unrecognised_directive("waypoint:lint-ignore E001 reason=\"x\""),
472            None
473        );
474        assert_eq!(
475            unrecognised_directive("waypoint:lint-ignore-file E001 reason=\"x\""),
476            None
477        );
478        assert_eq!(unrecognised_directive("just a normal comment"), None);
479        assert_eq!(unrecognised_directive("waypoint is a tool"), None);
480    }
481
482    #[test]
483    fn test_known_directives_still_parse_and_are_not_warned_about() {
484        let sql = "-- waypoint:require table_exists(\"a\")\n\
485                   -- waypoint:ensure table_exists(\"b\")\n\
486                   -- waypoint:env prod\n\
487                   -- waypoint:depends V1\n\
488                   -- waypoint:safety-override\n\
489                   SELECT 1;";
490        let d = parse_directives(sql);
491        assert_eq!(d.require.len(), 1);
492        assert_eq!(d.ensure.len(), 1);
493        assert_eq!(d.env, vec!["prod"]);
494        assert_eq!(d.depends, vec!["1"]);
495        assert!(d.safety_override);
496        // None of these should look unrecognised.
497        for body in [
498            "waypoint:require x",
499            "waypoint:ensure x",
500            "waypoint:env prod",
501            "waypoint:depends V1",
502            "waypoint:safety-override",
503        ] {
504            let head = unrecognised_directive(body);
505            assert!(
506                matches!(
507                    head,
508                    Some("require" | "ensure" | "env" | "depends" | "safety-override")
509                ),
510                "known directive {body:?} classified as {head:?}"
511            );
512        }
513    }
514}