cedar_policy_formatter/pprint/
fmt.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use std::collections::BTreeMap;
18
19use miette::{miette, Result, WrapErr};
20
21use cedar_policy_core::ast::PolicySet;
22use cedar_policy_core::parser::parse_policyset;
23use cedar_policy_core::parser::text_to_cst::parse_policies;
24use smol_str::ToSmolStr;
25
26use super::lexer::get_token_stream;
27use super::utils::remove_empty_lines;
28
29use super::config::{self, Config};
30use super::doc::*;
31
32fn tree_to_pretty<T: Doc>(t: &T, context: &mut config::Context<'_, '_>) -> Result<String> {
33    let mut w = Vec::new();
34    let config = context.config;
35    let doc = t.to_doc(context);
36    doc.ok_or_else(|| miette!("failed to produce doc"))?
37        .render(config.line_width, &mut w)
38        .map_err(|err| miette!(format!("failed to render doc: {err}")))?;
39    String::from_utf8(w)
40        .map_err(|err| miette!(format!("failed to convert rendered doc to string: {err}")))
41}
42
43fn soundness_check(ps: &str, ast: &PolicySet) -> Result<()> {
44    let formatted_ast =
45        parse_policyset(ps).wrap_err(format!("formatter produced an invalid policy set:\n{ps}"))?;
46    let (formatted_policies, policies) = (
47        formatted_ast
48            .policies()
49            .map(|p| (p.id().to_smolstr(), p))
50            .collect::<BTreeMap<_, _>>(),
51        ast.policies()
52            .map(|p| (p.id().to_smolstr(), p))
53            .collect::<BTreeMap<_, _>>(),
54    );
55
56    if formatted_policies.len() != policies.len() {
57        return Err(miette!(
58            "formatter changed the number of policies from {} to {}",
59            policies.len(),
60            formatted_policies.len()
61        ));
62    }
63    for ((f_p_id, f_p), (p_id, p)) in formatted_policies.into_iter().zip(policies.into_iter()) {
64        if f_p_id != p_id {
65            return Err(miette!(
66                "formatter changed the policy id from {p_id} to {f_p_id}"
67            ));
68        }
69        let (f_anno, anno) = (
70            f_p.annotations()
71                .map(|(k, v)| (k, &v.val))
72                .collect::<std::collections::BTreeMap<_, _>>(),
73            p.annotations()
74                .map(|(k, v)| (k, &v.val))
75                .collect::<std::collections::BTreeMap<_, _>>(),
76        );
77        if f_anno != anno {
78            return Err(miette!(
79                "formatter changed the annotations from {anno:?} to {f_anno:?}"
80            ));
81        }
82        if !(f_p.effect() == p.effect()
83            && f_p.principal_constraint() == p.principal_constraint()
84            && f_p.action_constraint() == p.action_constraint()
85            && f_p.resource_constraint() == p.resource_constraint()
86            && f_p
87                .non_scope_constraints()
88                .eq_shape(p.non_scope_constraints()))
89        {
90            return Err(miette!(
91                "formatter changed the policy structure:\noriginal:\n{p}\nformatted:\n{f_p}"
92            ));
93        }
94    }
95    Ok(())
96}
97
98pub fn policies_str_to_pretty(ps: &str, config: &Config) -> Result<String> {
99    let cst = parse_policies(ps).wrap_err("cannot parse input policies")?;
100    let ast = cst.to_policyset().wrap_err("cannot parse input policies")?;
101    let (tokens, end_of_file_comment) =
102        get_token_stream(ps).ok_or_else(|| miette!("cannot get token stream"))?;
103    let mut context = config::Context { config, tokens };
104    let mut formatted_policies = cst
105        .as_inner()
106        .ok_or_else(|| miette!("fail to get input policy CST"))?
107        .0
108        .iter()
109        .map(|p| Ok(remove_empty_lines(&tree_to_pretty(p, &mut context)?)))
110        .collect::<Result<Vec<String>>>()?
111        .join("\n\n");
112
113    // add a trailing newline
114    formatted_policies.push('\n');
115
116    // handle comment at the end of a policyset
117    for comment_line in end_of_file_comment {
118        formatted_policies.push_str(comment_line);
119        // note: each `comment_line` is guaranteed to never end with a newline
120        formatted_policies.push('\n');
121    }
122
123    // add soundness check to make sure formatting doesn't alter policy ASTs
124    soundness_check(&formatted_policies, &ast).wrap_err(
125        "internal error: please file an issue at <https://github.com/cedar-policy/cedar/issues>",
126    )?;
127    Ok(formatted_policies)
128}
129
130#[cfg(test)]
131mod tests {
132    use insta::{assert_snapshot, glob, with_settings};
133    use std::fs;
134
135    use super::*;
136
137    #[test]
138    fn test_soundness_check() {
139        let p1 = r#"permit (principal, action, resource)
140        when { "
141        
142        a
143        " };"#;
144        let p2 = r#"permit (principal, action, resource)
145        when { "
146        a
147        " };"#;
148        assert!(soundness_check(p2, &parse_policyset(p1).unwrap()).is_err());
149
150        let p1 = r#"
151        permit (principal, action, resource)
152        when { "a"};
153        permit (principal, action, resource)
154        when { "
155        
156        a
157        " };"#;
158        let p2 = r#"
159        permit (principal, action, resource)
160        when { "
161        a
162        " };
163        permit (principal, action, resource)
164        when { "a"};"#;
165        assert!(soundness_check(p2, &parse_policyset(p1).unwrap()).is_err());
166
167        let p1 = r#"
168        permit (principal, action, resource)
169        when { "a"   };
170        permit (principal, action, resource)
171        when { "b" };"#;
172        let p2 = r#"
173        permit (principal, action, resource)
174        when { "a" };
175        permit (principal, action, resource)
176        when { "b"};"#;
177        assert!(soundness_check(p2, &parse_policyset(p1).unwrap()).is_ok());
178    }
179
180    #[test]
181    fn test_add_trailing_newline() {
182        // The formatter should add a trailing newline.
183        // This behavior isn't tested by the snapshots below because `insta`
184        // ignores trailing whitespace.
185
186        let config = Config {
187            line_width: 80,
188            indent_width: 2,
189        };
190
191        let formatted_p = "permit (principal, action, resource);\n";
192        let p1 = "permit (principal, action, resource);";
193        let p2 = "permit (principal, action, resource);\r\n";
194        let p3 = "permit (principal, action, resource);\n\r\n\n";
195
196        assert_eq!(
197            policies_str_to_pretty(formatted_p, &config).unwrap(),
198            formatted_p
199        );
200        assert_eq!(policies_str_to_pretty(p1, &config).unwrap(), formatted_p);
201        assert_eq!(policies_str_to_pretty(p2, &config).unwrap(), formatted_p);
202        assert_eq!(policies_str_to_pretty(p3, &config).unwrap(), formatted_p);
203
204        let formatted_p = "permit (principal, action, resource);\n//foo\n";
205        let p1 = "permit (principal, action, resource);\n//foo";
206        let p2 = "permit (principal, action, resource);\n//foo\n\n\n";
207
208        assert_eq!(
209            policies_str_to_pretty(formatted_p, &config).unwrap(),
210            formatted_p
211        );
212        assert_eq!(policies_str_to_pretty(p1, &config).unwrap(), formatted_p);
213        assert_eq!(policies_str_to_pretty(p2, &config).unwrap(), formatted_p);
214
215        let formatted_p = "permit (principal, action, resource);\n//foo\n//bar\n";
216        let p1 = "permit (principal, action, resource);\n//foo\n//bar";
217        let p2 = "permit (principal, action, resource);\n//foo\n//bar ";
218        let p3 = "permit (principal, action, resource);\n//foo\n//bar\n\n\n";
219        let p4 = "permit (principal, action, resource);\n//foo\n//bar   \n\n\n";
220        let p5 = "permit (principal, action, resource);\n//foo\n//bar   \n \n \n";
221
222        assert_eq!(
223            policies_str_to_pretty(formatted_p, &config).unwrap(),
224            formatted_p
225        );
226        assert_eq!(policies_str_to_pretty(p1, &config).unwrap(), formatted_p);
227        assert_eq!(policies_str_to_pretty(p2, &config).unwrap(), formatted_p);
228        assert_eq!(policies_str_to_pretty(p3, &config).unwrap(), formatted_p);
229        assert_eq!(policies_str_to_pretty(p4, &config).unwrap(), formatted_p);
230        assert_eq!(policies_str_to_pretty(p5, &config).unwrap(), formatted_p);
231    }
232
233    #[test]
234    fn test_format_files() {
235        let config = Config {
236            line_width: 80,
237            indent_width: 2,
238        };
239
240        // This test uses `insta` to test the current output of the formatter
241        // against the output from prior versions. Run the test as usual with
242        // `cargo test`.
243        //
244        // If it fails, then use `cargo insta review` to review the diff between
245        // the current output and the snapshot. If the change is expected, you
246        // can accept the changes to make `insta` update the snapshot which you
247        // should the commit to the repository.
248        //
249        // Add new tests by placing a `.cedar` file in the test directory. The
250        // next run of `cargo test` will fail. Use `cargo insta review` to check
251        // the formatted output is expected.
252        with_settings!(
253            { snapshot_path => "../../tests/snapshots/" },
254            {
255                glob!("../../tests", "*.cedar", |path| {
256                    let cedar_source = fs::read_to_string(path).unwrap();
257                    let formatted = policies_str_to_pretty(&cedar_source, &config).unwrap();
258                    assert_snapshot!(formatted);
259                });
260            }
261        );
262
263        // Also check the CLI sample files.
264        with_settings!(
265            { snapshot_path => "../../tests/cli-snapshots/" },
266            {
267                glob!("../../../cedar-policy-cli/sample-data", "**/*.cedar", |path| {
268                    let cedar_source = fs::read_to_string(path).unwrap();
269                    let formatted = policies_str_to_pretty(&cedar_source, &config).unwrap();
270                    assert_snapshot!(formatted);
271                });
272            }
273        )
274    }
275}