Skip to main content

razeri_plugin_contract/
comment.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **How a comment is written into generated SQL.**
6//!
7//! Every comment Razeri writes is a block comment, `/* … */`. A line comment
8//! (`-- …`) ends at the end of its line, so a tool that joins a script's lines
9//! into one, as a minifier or a copy through a single-line field does, turns
10//! everything after the first `--` into comment, and the statements that
11//! followed it are gone without an error. A block comment closes itself, so
12//! the same script still runs with its line endings turned into spaces.
13//!
14//! [`SqlRenderer::render_comment`](crate::SqlRenderer::render_comment) is the
15//! one place a dialect can change that, and its default is [`block_comment`].
16//! No dialect Razeri carries overrides it: every engine it writes SQL for
17//! reads `/* … */`.
18
19/// **A comment in the block form**, ready to stand on its own lines.
20///
21/// One line of text is written `/* text */`. Several lines are written with
22/// the first after the opening `/* `, each line after it indented three
23/// spaces so it lines up under the first, and the closing ` */` at the end of
24/// the last. An empty line of text stays empty.
25///
26/// The text cannot end the comment early or open another. `*/` in it is
27/// written `* /`, and `/*` is written `/ *`, because SQL Server and
28/// `PostgreSQL` nest block comments and a stray `/*` would hide the rest of the
29/// script. Every comment opens with `/* ` and a space, so it is never read
30/// as `MySQL`'s `/*!` or an optimizer hint's `/*+`.
31///
32/// Trailing spaces are dropped from each line, and so are empty lines at the
33/// start and the end.
34#[must_use]
35pub fn block_comment(text: &str) -> String {
36    let safe = escaped(text);
37    let lines: Vec<&str> = safe.lines().map(str::trim_end).collect();
38    let first = lines.iter().position(|line| !line.is_empty());
39    let last = lines.iter().rposition(|line| !line.is_empty());
40    let (Some(first), Some(last)) = (first, last) else {
41        return "/* */".to_owned();
42    };
43    let lines = &lines[first..=last];
44    if let [only] = lines {
45        return format!("/* {only} */");
46    }
47    let mut out = String::with_capacity(safe.len() + lines.len() * 4 + 6);
48    for (at, line) in lines.iter().enumerate() {
49        if at == 0 {
50            out.push_str("/* ");
51        } else {
52            out.push('\n');
53            if !line.is_empty() {
54                out.push_str(BLOCK_COMMENT_HANG);
55            }
56        }
57        out.push_str(line);
58    }
59    out.push_str(" */");
60    out
61}
62
63/// **A cron expression as a comment can carry it**, with the same schedule.
64///
65/// A step over a whole field, `*/6`, holds the two characters that end a block
66/// comment, and [`block_comment`] breaks them with a space, which would leave a
67/// reader a different expression. So each such step is written over the
68/// field's own range instead, `0-23/6` in the hours: the schedule is the same,
69/// and nothing in it can end the comment. A five-field expression reads minute,
70/// hour, day of month, month and day of week; six or seven fields read a
71/// second first, as Quartz does, with its days of the week numbered 1 to 7 and
72/// a year last. An expression of any other length is returned as it is.
73#[must_use]
74pub fn cron_in_comment(expression: &str) -> String {
75    const FIVE: [(u32, u32); 5] = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)];
76    const SEVEN: [(u32, u32); 7] =
77        [(0, 59), (0, 59), (0, 23), (1, 31), (1, 12), (1, 7), (1970, 2099)];
78    let fields: Vec<&str> = expression.split_whitespace().collect();
79    let ranges: &[(u32, u32)] = match fields.len() {
80        5 => &FIVE,
81        6 | 7 => &SEVEN[..fields.len()],
82        _ => return expression.to_owned(),
83    };
84    fields
85        .iter()
86        .zip(ranges)
87        .map(|(field, (low, high))| {
88            field
89                .split(',')
90                .map(|part| match part.strip_prefix("*/") {
91                    Some(step) => format!("{low}-{high}/{step}"),
92                    None => part.to_owned(),
93                })
94                .collect::<Vec<_>>()
95                .join(",")
96        })
97        .collect::<Vec<_>>()
98        .join(" ")
99}
100
101/// What each line of a block comment after its first is indented by, so it
102/// lines up under the text that follows the opening `/* `.
103pub const BLOCK_COMMENT_HANG: &str = "   ";
104
105/// The comment's text with every `*/` and `/*` broken by a space.
106fn escaped(text: &str) -> String {
107    let mut out = String::with_capacity(text.len());
108    let mut chars = text.chars().peekable();
109    while let Some(c) = chars.next() {
110        out.push(c);
111        let next = chars.peek().copied();
112        if (c == '*' && next == Some('/')) || (c == '/' && next == Some('*')) {
113            out.push(' ');
114        }
115    }
116    out
117}
118
119/// **A statement's comment lines, apart from its other lines**: each line
120/// that is wholly comment (inside a block comment, or opening one, or a line
121/// comment), and the rest, each in its order.
122///
123/// For a reader that has to move a statement's notes somewhere else, as one
124/// engine's `EXEC '…'` cannot carry them inside its string.
125#[must_use]
126pub fn comment_lines_apart(statement: &str) -> (Vec<&str>, Vec<&str>) {
127    let mut notes = Vec::new();
128    let mut body = Vec::new();
129    for (line, is_note) in statement.lines().zip(comment_line_flags(statement)) {
130        if is_note {
131            notes.push(line);
132        } else {
133            body.push(line);
134        }
135    }
136    (notes, body)
137}
138
139/// **Whether each line of `text` is wholly comment**: inside a block comment,
140/// opening one, or a line comment. One flag per line, in order.
141#[must_use]
142pub fn comment_line_flags(text: &str) -> Vec<bool> {
143    let mut flags = Vec::new();
144    let mut open = false;
145    for line in text.lines() {
146        let trimmed = line.trim_start();
147        let line_comment = !open && trimmed.starts_with("--");
148        let is_note = open || line_comment || trimmed.starts_with("/*");
149        if is_note && !line_comment {
150            // Whether the block is still open at the end of this line.
151            let mut depth = usize::from(open);
152            let mut chars = trimmed.chars().peekable();
153            while let Some(c) = chars.next() {
154                if c == '/' && chars.peek() == Some(&'*') {
155                    chars.next();
156                    depth += 1;
157                } else if c == '*' && chars.peek() == Some(&'/') {
158                    chars.next();
159                    depth = depth.saturating_sub(1);
160                }
161            }
162            open = depth > 0;
163        }
164        flags.push(is_note);
165    }
166    flags
167}
168
169#[cfg(test)]
170mod tests {
171
172    #[test]
173    fn a_cron_step_over_a_whole_field_keeps_its_schedule_and_closes_nothing() {
174        assert_eq!(cron_in_comment("0 */6 * * *"), "0 0-23/6 * * *");
175        assert_eq!(cron_in_comment("*/15 * * * 1-5"), "0-59/15 * * * 1-5");
176        assert_eq!(cron_in_comment("0 0 */2 * * ?"), "0 0 0-23/2 * * ?");
177        assert_eq!(cron_in_comment("0 3 * * *"), "0 3 * * *");
178        assert_eq!(cron_in_comment("@daily"), "@daily");
179        let said = block_comment(&format!("Schedule: cron {}", cron_in_comment("0 */6 * * *")));
180        assert_eq!(said, "/* Schedule: cron 0 0-23/6 * * * */");
181    }
182    use super::*;
183
184    #[test]
185    fn one_line_is_one_block() {
186        assert_eq!(
187            block_comment("Merge in new and updated rows"),
188            "/* Merge in new and updated rows */"
189        );
190    }
191
192    #[test]
193    fn several_lines_hang_under_the_first() {
194        assert_eq!(
195            block_comment("Stop if a row has no key.\nNothing is written.\n\nThe end."),
196            "/* Stop if a row has no key.\n   Nothing is written.\n\n   The end. */"
197        );
198    }
199
200    #[test]
201    fn a_closing_mark_in_the_text_cannot_end_the_comment() {
202        let rendered = block_comment("a pattern such as '*/' or '/*' or */*/");
203        let inner = &rendered[3..rendered.len() - 3];
204        assert!(!inner.contains("*/") && !inner.contains("/*"), "{rendered}");
205        assert_eq!(rendered, "/* a pattern such as '* /' or '/ *' or * / * / */");
206    }
207
208    #[test]
209    fn a_comment_is_never_a_hint_or_a_version_comment() {
210        assert!(block_comment("!50001 not a version comment").starts_with("/* !"));
211        assert!(block_comment("+ INDEX(t) not a hint").starts_with("/* +"));
212    }
213
214    #[test]
215    fn empty_text_is_an_empty_comment() {
216        assert_eq!(block_comment(""), "/* */");
217        assert_eq!(block_comment("\n  \n"), "/* */");
218    }
219
220    #[test]
221    fn a_rule_of_dashes_inside_a_block_is_the_blocks_own_line() {
222        let text = "/* ----\n   Entity: t\n   ---- */\nCREATE TABLE t (a INT);";
223        assert_eq!(comment_line_flags(text), [true, true, true, false]);
224    }
225
226    #[test]
227    fn a_statements_notes_come_apart_from_its_lines() {
228        let statement = "/* one\n   two */\nCREATE TABLE t (a INT);\n-- three\nSELECT 1";
229        let (notes, body) = comment_lines_apart(statement);
230        assert_eq!(notes, ["/* one", "   two */", "-- three"]);
231        assert_eq!(body, ["CREATE TABLE t (a INT);", "SELECT 1"]);
232    }
233
234    #[test]
235    fn trailing_spaces_and_outer_blank_lines_go() {
236        assert_eq!(block_comment("\nfirst   \nsecond\n\n"), "/* first\n   second */");
237    }
238}