diff --git a/packages/autofmt/src/writer.rs b/packages/autofmt/src/writer.rs index 18ad067cb..26e2aa594 100644 --- a/packages/autofmt/src/writer.rs +++ b/packages/autofmt/src/writer.rs @@ -5,7 +5,7 @@ use quote::ToTokens; use regex::Regex; use std::{ borrow::Cow, - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, fmt::{Result, Write}, }; use syn::{spanned::Spanned, token::Brace, Expr}; @@ -848,92 +848,200 @@ impl<'a> Writer<'a> { static COMMENT_REGEX: Regex = Regex::new("\"[^\"]*\"|(//.*)").unwrap(); } - let pretty_expr = self.retrieve_formatted_expr(&expr).to_string(); + let pretty = self.retrieve_formatted_expr(&expr).to_string(); + let source = src_span.source_text().unwrap_or_default(); + let mut src_lines = source.lines().peekable(); - // Adding comments back to the formatted expression - let source_text = src_span.source_text().unwrap_or_default(); - let mut source_lines = source_text.lines().peekable(); - let mut output = String::from(""); - let mut printed_empty_line = false; + // Comments already in pretty output (from nested rsx!) - skip these from source + let pretty_comments: HashSet<_> = pretty + .lines() + .filter(|l| l.trim().starts_with("//")) + .map(|l| l.trim()) + .collect(); - if source_lines.peek().is_none() { - output = pretty_expr; + let mut out = String::new(); + + if src_lines.peek().is_none() { + out = pretty; } else { - for line in pretty_expr.lines() { - let compacted_pretty_line = line.replace(" ", "").replace(",", ""); - let trimmed_pretty_line = line.trim(); + for line in pretty.lines() { + let trimmed = line.trim(); + let compacted = line.replace(" ", "").replace(",", ""); - // Nested expressions might have comments already. We handle writing all of those - // at the outer level, so we skip them here - if trimmed_pretty_line.starts_with("//") { + // Pretty comments: consume matching source lines, preserve preceding empty lines + if trimmed.starts_with("//") { + if !out.is_empty() { + out.push('\n'); + } + let mut had_empty = false; + while let Some(s) = src_lines.peek() { + let t = s.trim(); + if t.is_empty() { + had_empty = true; + src_lines.next(); + } else if t == trimmed { + src_lines.next(); + break; + } else { + break; + } + } + if had_empty { + out.push('\n'); + } + out.push_str(line); continue; } - if !output.is_empty() { - output.push('\n'); + // Pretty empty lines: preserve and sync with source + if trimmed.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + while src_lines + .peek() + .map(|s| s.trim().is_empty()) + .unwrap_or(false) + { + src_lines.next(); + } + continue; } - // pull down any source lines with whitespace until we hit a line that matches our current line. - while let Some(src) = source_lines.peek() { - let trimmed_src = src.trim(); + if !out.is_empty() { + out.push('\n'); + } - // Write comments and empty lines as they are - if trimmed_src.starts_with("//") || trimmed_src.is_empty() { - if !trimmed_src.is_empty() { - // Match the whitespace of the incoming source line - for s in line.chars().take_while(|c| c.is_whitespace()) { - output.push(s); + // Scan source for comments/empty lines before the matching line + let mut pending_comments = Vec::new(); + let mut had_empty = false; + let mut multiline: Option> = None; + + while let Some(src) = src_lines.peek() { + let src_trimmed = src.trim(); + + if src_trimmed.is_empty() || src_trimmed.starts_with("//") { + if src_trimmed.is_empty() { + if pending_comments.is_empty() { + had_empty = true; } - - // Bump out the indent level if the line starts with a closing brace (ie we're at the end of a block) - if matches!(trimmed_pretty_line.chars().next(), Some(')' | '}' | ']')) { - output.push_str(self.out.indent.indent_str()); - } - - printed_empty_line = false; - output.push_str(trimmed_src); - output.push('\n'); - } else if !printed_empty_line { - output.push('\n'); - printed_empty_line = true; + } else if !pretty_comments.contains(src_trimmed) { + pending_comments.push(src_trimmed); } - - _ = source_lines.next(); + src_lines.next(); continue; } - let compacted_src_line = src.replace(" ", "").replace(",", ""); + let src_compacted = src.replace(" ", "").replace(",", ""); - // If this source line matches our pretty line, we stop pulling down - if compacted_src_line.contains(&compacted_pretty_line) { + // Exact match + if src_compacted.contains(&compacted) { break; } - // Otherwise, consume this source line and keep going - _ = source_lines.next(); + // Multi-line method chain (e.g., foo\n .bar()\n .baz()) + if !src_compacted.is_empty() && compacted.starts_with(&src_compacted) { + let is_call = src_trimmed.ends_with('(') + || src_trimmed.ends_with(',') + || src_trimmed.ends_with('{'); + if !is_call { + multiline = Some(vec![*src]); + break; + } + } + + // Non-matching line - clear pending and skip + pending_comments.clear(); + had_empty = false; + src_lines.next(); + break; } - // Once all whitespace is written, write the pretty line - output.push_str(line); - printed_empty_line = false; + // Output empty line if needed + if had_empty { + out.push('\n'); + } - // And then pull the corresponding source line - let source_line = source_lines.next(); + // Output pending comments + for comment in &pending_comments { + for c in line.chars().take_while(|c| c.is_whitespace()) { + out.push(c); + } + if matches!(trimmed.chars().next(), Some(')' | '}' | ']')) { + out.push_str(self.out.indent.indent_str()); + } + out.push_str(comment); + out.push('\n'); + } - // And then write any inline comments - if let Some(source_line) = source_line { - if let Some(captures) = COMMENT_REGEX.with(|f| f.captures(source_line)) { - if let Some(comment) = captures.get(1) { - output.push_str(" // "); - output.push_str(comment.as_str().replace("//", "").trim()); + // Handle multi-line method chains + if let Some(mut ml) = multiline { + src_lines.next(); + let mut acc = ml[0].replace(" ", "").replace(",", ""); + + while let Some(src) = src_lines.peek() { + let t = src.trim(); + if t.starts_with("//") { + ml.push(src); + src_lines.next(); + continue; + } + if t.is_empty() { + src_lines.next(); + continue; + } + + acc.push_str(&src.replace(" ", "").replace(",", "")); + ml.push(src); + + if acc.contains(&compacted) { + src_lines.next(); + break; + } + + let cont = t.starts_with('.') + || t.starts_with("&&") + || t.starts_with("||") + || matches!(t.chars().next(), Some('+' | '-' | '*' | '/' | '?')); + + if cont || compacted.starts_with(&acc) { + src_lines.next(); + continue; + } + break; + } + + // Write multi-line with adjusted indentation + let base_indent = ml[0].chars().take_while(|c| c.is_whitespace()).count(); + let target: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + + for (i, src_line) in ml.iter().enumerate() { + let indent = src_line.chars().take_while(|c| c.is_whitespace()).count(); + out.push_str(&target); + for _ in 0..indent.saturating_sub(base_indent) { + out.push(' '); + } + out.push_str(src_line.trim()); + if i < ml.len() - 1 { + out.push('\n'); + } + } + } else { + // Single line - output pretty line and capture inline comments + out.push_str(line); + if let Some(src_line) = src_lines.next() { + if let Some(cap) = COMMENT_REGEX.with(|r| r.captures(src_line)) { + if let Some(c) = cap.get(1) { + out.push_str(" // "); + out.push_str(c.as_str().replace("//", "").trim()); + } } } } } } - self.write_mulitiline_tokens(output)?; - + self.write_mulitiline_tokens(out)?; Ok(()) } diff --git a/packages/autofmt/tests/wrong.rs b/packages/autofmt/tests/wrong.rs index 5fcb0145a..8cf0eb32b 100644 --- a/packages/autofmt/tests/wrong.rs +++ b/packages/autofmt/tests/wrong.rs @@ -41,3 +41,4 @@ twoway!("syntax_error" => syntax_error (IndentOptions::new(IndentType::Spaces, 4 twoway!("skipfail" => skipfail (IndentOptions::new(IndentType::Spaces, 4, false))); twoway!("comments-inline-4sp" => comments_inline_4sp (IndentOptions::new(IndentType::Spaces, 4, false))); twoway!("comments-attributes-4sp" => comments_attributes_4sp (IndentOptions::new(IndentType::Spaces, 4, false))); +twoway!("comments-big" => comments_big (IndentOptions::new(IndentType::Spaces, 4, false))); diff --git a/packages/autofmt/tests/wrong/comments-big.rsx b/packages/autofmt/tests/wrong/comments-big.rsx new file mode 100644 index 000000000..9f6f507b6 --- /dev/null +++ b/packages/autofmt/tests/wrong/comments-big.rsx @@ -0,0 +1,250 @@ +pub fn MediaPlayerControls() -> Element { + rsx! { + div { + // Control Panel Row + nav { class: "h-12 border-b border-gray-200 dark:border-[#222] flex items-center justify-between px-6 shrink-0 bg-gray-50 dark:bg-[#000000]", + // Right: Playback controls + Settings + div { class: "flex items-center gap-2", + // Playback buttons - shown only when track is loaded + if let Some(track_id_str) = ¤t_track_id { + // Get current track from the tracks hashmap + { + let current_track = tracks().get(track_id_str).cloned(); + let track_id_owned = track_id_str.clone(); + + rsx! { + // Play and Download buttons + if current_track.is_some() { + { + rsx! { + // Play button - streams audio to device + button { + class: "px-2.5 py-1 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer disabled:opacity-50", + title: "Play track (streams audio)", + disabled: is_buffering(), + onclick: { + let tid = track_id_owned.clone(); + // Capture output device for streaming + let output_device = current_track + .as_ref() + .and_then(|t| t.preferred_output.clone()); + move |_| { + let tid = tid.clone(); + let output_device = output_device.clone(); + async move { + playback_result.set(None); + playback_error.set(None); + is_buffering.set(true); + + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + let request = StreamAudioRequest {}; + + match start_audio_stream(track_id, request).await { + Ok(stream) => { + // Use output device if available, otherwise fall back to default + let device = output_device + .map(|d| format!("{}/", d)) + .unwrap_or_else(|| "/dev/audio/default".to_string()); + + // Connect to the audio stream endpoint + let stream_url = format!( + "audio://stream/{}@{}{}", + stream.session_token, + stream.server_host, + device, + ); + // Open the audio stream + #[cfg(feature = "web")] + if let Some(window) = web_sys::window() { + let _ = window.location().set_href(&stream_url); + } + #[cfg(not(feature = "web"))] + let _ = stream_url; + } + Err(e) => { + playback_error + .set(Some(format!("Failed to start stream: {}", e))); + show_error_modal.set(true); + } + } + is_buffering.set(false); + } + } + }, + Icon { + name: "play_arrow", + size: 16, + color: IconColor::Custom("#2563eb".to_string()), + } + if is_buffering() { + "Buffering..." + } else if SHOW_CONTROL_TEXT { + "Play" + } + } + + // Download button - downloads track locally + button { + class: "px-2.5 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white cursor-pointer disabled:opacity-50", + title: "Download track for offline playback", + disabled: is_buffering(), + onclick: { + let tid = track_id_owned.clone(); + move |_| { + let tid = tid.clone(); + async move { + playback_result.set(None); + playback_error.set(None); + is_buffering.set(true); + show_download_modal.set(true); + + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + let request = DownloadTrackRequest {}; + + match download_track(track_id, request).await { + Ok(download) => { + playback_result.set(Some(download)); + } + Err(e) => { + playback_error + .set(Some(format!("Failed to download: {}", e))); + } + } + is_buffering.set(false); + } + } + }, + Icon { + name: "download", + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if is_buffering() { + "Downloading..." + } else if SHOW_CONTROL_TEXT { + "Download" + } + } + } + } + } + + // Fullscreen mode toggle + button { + class: "px-2.5 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white mr-2 pr-3 border-r border-gray-200 dark:border-[#333] cursor-pointer", + title: if (ctx.fullscreen)() { "Exit fullscreen" } else { "Enter fullscreen" }, + onclick: move |_| ctx.fullscreen.set(!(ctx.fullscreen)()), + Icon { + name: if (ctx.fullscreen)() { "fullscreen_exit" } else { "fullscreen" }, + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if SHOW_CONTROL_TEXT { + if (ctx.fullscreen)() { + "Exit Fullscreen" + } else { + "Fullscreen" + } + } + } + + div { class: "flex items-center gap-1 mr-2 pr-3 border-r border-gray-200 dark:border-[#333]", + button { + class: "px-2 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white cursor-pointer", + title: "Add to playlist", + onclick: move |_| show_playlist_modal.set(true), + Icon { + name: "playlist_add", + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if SHOW_CONTROL_TEXT { + "Add to Playlist" + } + } + + if let Some(ref track) = current_track { + { + let track_id_for_action = track_id_owned.clone(); + match track.status { + TrackStatus::Playing { .. } => rsx! { + button { + class: "px-2 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white cursor-pointer", + title: "Pause playback", + onclick: move |_| { + let tid = track_id_for_action.clone(); + async move { + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + if let Err(e) = pause_track(track_id).await { + error!("Failed to pause: {:?}", e); + } + } + }, + Icon { + name: "pause", + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if SHOW_CONTROL_TEXT { + "Pause" + } + } + }, + TrackStatus::Paused { .. } => rsx! { + button { + class: "px-2 py-1 bg-green-600 hover:bg-green-500 text-white rounded transition-colors flex items-center gap-1.5 text-sm cursor-pointer", + title: "Resume playback", + onclick: move |_| { + let tid = track_id_for_action.clone(); + async move { + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + if let Err(e) = resume_track(track_id).await { + error!("Failed to resume: {:?}", e); + } + } + }, + Icon { name: "play_arrow", size: 16, color: IconColor::Light } + if SHOW_CONTROL_TEXT { + "Resume" + } + } + }, + _ => rsx! {}, + } + } + } + + { + let track_id_for_stop = track_id_owned.clone(); + rsx! { + button { + class: "px-2 py-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer", + title: "Stop playback", + onclick: move |_| { + let tid = track_id_for_stop.clone(); + async move { + if let Err(e) = stop_track(tid).await { + error!("Failed to stop: {:?}", e); + } + } + }, + Icon { + name: "stop", + size: 16, + color: IconColor::Custom("#dc2626".to_string()), + } + if SHOW_CONTROL_TEXT { + "Stop" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/packages/autofmt/tests/wrong/comments-big.wrong.rsx b/packages/autofmt/tests/wrong/comments-big.wrong.rsx new file mode 100644 index 000000000..9f6f507b6 --- /dev/null +++ b/packages/autofmt/tests/wrong/comments-big.wrong.rsx @@ -0,0 +1,250 @@ +pub fn MediaPlayerControls() -> Element { + rsx! { + div { + // Control Panel Row + nav { class: "h-12 border-b border-gray-200 dark:border-[#222] flex items-center justify-between px-6 shrink-0 bg-gray-50 dark:bg-[#000000]", + // Right: Playback controls + Settings + div { class: "flex items-center gap-2", + // Playback buttons - shown only when track is loaded + if let Some(track_id_str) = ¤t_track_id { + // Get current track from the tracks hashmap + { + let current_track = tracks().get(track_id_str).cloned(); + let track_id_owned = track_id_str.clone(); + + rsx! { + // Play and Download buttons + if current_track.is_some() { + { + rsx! { + // Play button - streams audio to device + button { + class: "px-2.5 py-1 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer disabled:opacity-50", + title: "Play track (streams audio)", + disabled: is_buffering(), + onclick: { + let tid = track_id_owned.clone(); + // Capture output device for streaming + let output_device = current_track + .as_ref() + .and_then(|t| t.preferred_output.clone()); + move |_| { + let tid = tid.clone(); + let output_device = output_device.clone(); + async move { + playback_result.set(None); + playback_error.set(None); + is_buffering.set(true); + + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + let request = StreamAudioRequest {}; + + match start_audio_stream(track_id, request).await { + Ok(stream) => { + // Use output device if available, otherwise fall back to default + let device = output_device + .map(|d| format!("{}/", d)) + .unwrap_or_else(|| "/dev/audio/default".to_string()); + + // Connect to the audio stream endpoint + let stream_url = format!( + "audio://stream/{}@{}{}", + stream.session_token, + stream.server_host, + device, + ); + // Open the audio stream + #[cfg(feature = "web")] + if let Some(window) = web_sys::window() { + let _ = window.location().set_href(&stream_url); + } + #[cfg(not(feature = "web"))] + let _ = stream_url; + } + Err(e) => { + playback_error + .set(Some(format!("Failed to start stream: {}", e))); + show_error_modal.set(true); + } + } + is_buffering.set(false); + } + } + }, + Icon { + name: "play_arrow", + size: 16, + color: IconColor::Custom("#2563eb".to_string()), + } + if is_buffering() { + "Buffering..." + } else if SHOW_CONTROL_TEXT { + "Play" + } + } + + // Download button - downloads track locally + button { + class: "px-2.5 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white cursor-pointer disabled:opacity-50", + title: "Download track for offline playback", + disabled: is_buffering(), + onclick: { + let tid = track_id_owned.clone(); + move |_| { + let tid = tid.clone(); + async move { + playback_result.set(None); + playback_error.set(None); + is_buffering.set(true); + show_download_modal.set(true); + + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + let request = DownloadTrackRequest {}; + + match download_track(track_id, request).await { + Ok(download) => { + playback_result.set(Some(download)); + } + Err(e) => { + playback_error + .set(Some(format!("Failed to download: {}", e))); + } + } + is_buffering.set(false); + } + } + }, + Icon { + name: "download", + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if is_buffering() { + "Downloading..." + } else if SHOW_CONTROL_TEXT { + "Download" + } + } + } + } + } + + // Fullscreen mode toggle + button { + class: "px-2.5 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white mr-2 pr-3 border-r border-gray-200 dark:border-[#333] cursor-pointer", + title: if (ctx.fullscreen)() { "Exit fullscreen" } else { "Enter fullscreen" }, + onclick: move |_| ctx.fullscreen.set(!(ctx.fullscreen)()), + Icon { + name: if (ctx.fullscreen)() { "fullscreen_exit" } else { "fullscreen" }, + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if SHOW_CONTROL_TEXT { + if (ctx.fullscreen)() { + "Exit Fullscreen" + } else { + "Fullscreen" + } + } + } + + div { class: "flex items-center gap-1 mr-2 pr-3 border-r border-gray-200 dark:border-[#333]", + button { + class: "px-2 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white cursor-pointer", + title: "Add to playlist", + onclick: move |_| show_playlist_modal.set(true), + Icon { + name: "playlist_add", + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if SHOW_CONTROL_TEXT { + "Add to Playlist" + } + } + + if let Some(ref track) = current_track { + { + let track_id_for_action = track_id_owned.clone(); + match track.status { + TrackStatus::Playing { .. } => rsx! { + button { + class: "px-2 py-1 hover:bg-gray-100 dark:hover:bg-[#252525] rounded transition-colors flex items-center gap-1.5 text-sm text-gray-600 dark:text-[#888] hover:text-gray-900 dark:hover:text-white cursor-pointer", + title: "Pause playback", + onclick: move |_| { + let tid = track_id_for_action.clone(); + async move { + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + if let Err(e) = pause_track(track_id).await { + error!("Failed to pause: {:?}", e); + } + } + }, + Icon { + name: "pause", + size: 16, + color: IconColor::Custom("#666".to_string()), + } + if SHOW_CONTROL_TEXT { + "Pause" + } + } + }, + TrackStatus::Paused { .. } => rsx! { + button { + class: "px-2 py-1 bg-green-600 hover:bg-green-500 text-white rounded transition-colors flex items-center gap-1.5 text-sm cursor-pointer", + title: "Resume playback", + onclick: move |_| { + let tid = track_id_for_action.clone(); + async move { + let track_id: uuid::Uuid = tid.parse().unwrap_or_default(); + if let Err(e) = resume_track(track_id).await { + error!("Failed to resume: {:?}", e); + } + } + }, + Icon { name: "play_arrow", size: 16, color: IconColor::Light } + if SHOW_CONTROL_TEXT { + "Resume" + } + } + }, + _ => rsx! {}, + } + } + } + + { + let track_id_for_stop = track_id_owned.clone(); + rsx! { + button { + class: "px-2 py-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 cursor-pointer", + title: "Stop playback", + onclick: move |_| { + let tid = track_id_for_stop.clone(); + async move { + if let Err(e) = stop_track(tid).await { + error!("Failed to stop: {:?}", e); + } + } + }, + Icon { + name: "stop", + size: 16, + color: IconColor::Custom("#dc2626".to_string()), + } + if SHOW_CONTROL_TEXT { + "Stop" + } + } + } + } + } + } + } + } + } + } + } + } +}