@@ -122,3 +122,111 @@ fn test_snapshots_roundtrip() {
122122 ) ;
123123 }
124124}
125+
126+ /// Test full roundtrip: parse -> `to_bash` -> parse -> compare AST
127+ ///
128+ /// This verifies that `to_bash` produces valid bash that parses to an equivalent AST.
129+ /// Line numbers are ignored since regenerated code has different formatting.
130+ #[ test]
131+ fn test_snapshots_to_bash_roundtrip ( ) {
132+ use bash_ast:: { parse, to_bash} ;
133+
134+ setup ( ) ;
135+
136+ let snapshot_dir = Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) ) . join ( "tests/snapshots" ) ;
137+
138+ // Known failures due to heredoc limitations
139+ let known_failures = [ "30_multiple_heredocs.sh" , "49_heredoc_variations.sh" ] ;
140+
141+ let mut scripts: Vec < _ > = fs:: read_dir ( & snapshot_dir)
142+ . expect ( "Failed to read snapshots directory" )
143+ . filter_map ( std:: result:: Result :: ok)
144+ . map ( |e| e. path ( ) )
145+ . filter ( |p| p. extension ( ) . is_some_and ( |ext| ext == "sh" ) )
146+ . collect ( ) ;
147+
148+ scripts. sort ( ) ;
149+
150+ let mut failures = Vec :: new ( ) ;
151+
152+ for script_path in & scripts {
153+ let script_name = script_path. file_name ( ) . unwrap ( ) . to_string_lossy ( ) ;
154+
155+ // Skip known failures
156+ if known_failures. iter ( ) . any ( |& f| script_name == f) {
157+ continue ;
158+ }
159+
160+ let script = fs:: read_to_string ( script_path)
161+ . unwrap_or_else ( |e| panic ! ( "Failed to read {script_path:?}: {e}" ) ) ;
162+
163+ // Parse original
164+ let ast1 = match parse ( & script) {
165+ Ok ( ast) => ast,
166+ Err ( e) => {
167+ failures. push ( format ! ( "{script_name}: failed to parse original: {e}" ) ) ;
168+ continue ;
169+ }
170+ } ;
171+
172+ // Convert to bash
173+ let regenerated = to_bash ( & ast1) ;
174+
175+ // Parse regenerated
176+ let ast2 = match parse ( & regenerated) {
177+ Ok ( ast) => ast,
178+ Err ( e) => {
179+ failures. push ( format ! (
180+ "{script_name}: failed to parse regenerated script: {e}\n Regenerated:\n {regenerated}"
181+ ) ) ;
182+ continue ;
183+ }
184+ } ;
185+
186+ // Compare ASTs (ignoring line numbers)
187+ let json1 = serde_json:: to_string ( & ast1) . unwrap ( ) ;
188+ let json2 = serde_json:: to_string ( & ast2) . unwrap ( ) ;
189+
190+ let json1_normalized = normalize_json_for_comparison ( & json1) ;
191+ let json2_normalized = normalize_json_for_comparison ( & json2) ;
192+
193+ if json1_normalized != json2_normalized {
194+ failures. push ( format ! (
195+ "{script_name}: AST mismatch after roundtrip\n Original AST:\n {json1}\n Regenerated script:\n {regenerated}\n Regenerated AST:\n {json2}"
196+ ) ) ;
197+ }
198+ }
199+
200+ assert ! (
201+ failures. is_empty( ) ,
202+ "\n {} roundtrip test(s) failed:\n \n {}" ,
203+ failures. len( ) ,
204+ failures. join( "\n \n ---\n \n " )
205+ ) ;
206+ }
207+
208+ /// Remove line numbers from JSON for comparison
209+ fn normalize_json_for_comparison ( json : & str ) -> String {
210+ // Parse and re-serialize without line numbers
211+ let mut value: serde_json:: Value = serde_json:: from_str ( json) . unwrap ( ) ;
212+ remove_line_numbers ( & mut value) ;
213+ serde_json:: to_string ( & value) . unwrap ( )
214+ }
215+
216+ /// Recursively remove "line" fields from JSON value
217+ fn remove_line_numbers ( value : & mut serde_json:: Value ) {
218+ match value {
219+ serde_json:: Value :: Object ( map) => {
220+ map. remove ( "line" ) ;
221+ for v in map. values_mut ( ) {
222+ remove_line_numbers ( v) ;
223+ }
224+ }
225+ serde_json:: Value :: Array ( arr) => {
226+ for v in arr {
227+ remove_line_numbers ( v) ;
228+ }
229+ }
230+ _ => { }
231+ }
232+ }
0 commit comments