@@ -61,6 +61,11 @@ impl LanguageServer for Backend {
6161 ..Default::default()
6262 }),
6363 hover_provider: Some(HoverProviderCapability::Simple(true)),
64+ definition_provider: Some(OneOf::Left(true)),
65+ rename_provider: Some(OneOf::Right(RenameOptions {
66+ prepare_provider: Some(true),
67+ work_done_progress_options: Default::default(),
68+ })),
6469 ..Default::default()
6570 },
6671 ..Default::default()
@@ -127,6 +132,52 @@ impl LanguageServer for Backend {
127132
128133 Ok(hover_at(&text, pos))
129134 }
135+
136+ async fn goto_definition(
137+ &self,
138+ params: GotoDefinitionParams,
139+ ) -> Result<Option<GotoDefinitionResponse>> {
140+ let uri = params.text_document_position_params.text_document.uri.clone();
141+ let pos = params.text_document_position_params.position;
142+ let docs = self.documents.read().await;
143+ let text = match docs.get(&uri) {
144+ Some(t) => t.clone(),
145+ None => return Ok(None),
146+ };
147+ drop(docs);
148+
149+ Ok(definition_at(&text, pos, &uri))
150+ }
151+
152+ async fn prepare_rename(
153+ &self,
154+ params: TextDocumentPositionParams,
155+ ) -> Result<Option<PrepareRenameResponse>> {
156+ let uri = ¶ms.text_document.uri;
157+ let pos = params.position;
158+ let docs = self.documents.read().await;
159+ let text = match docs.get(uri) {
160+ Some(t) => t.clone(),
161+ None => return Ok(None),
162+ };
163+ drop(docs);
164+
165+ Ok(prepare_rename_at(&text, pos))
166+ }
167+
168+ async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
169+ let uri = params.text_document_position.text_document.uri.clone();
170+ let pos = params.text_document_position.position;
171+ let new_name = params.new_name;
172+ let docs = self.documents.read().await;
173+ let text = match docs.get(&uri) {
174+ Some(t) => t.clone(),
175+ None => return Ok(None),
176+ };
177+ drop(docs);
178+
179+ Ok(rename_at(&text, pos, &new_name, &uri))
180+ }
130181}
131182
132183// ---------------------------------------------------------------------------
@@ -264,6 +315,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
264315 ("@let", "Define a variable", "@let "),
265316 ("@define", "Define an attribute bundle", "@define "),
266317 ("@fn", "Define a reusable function", "@fn "),
318+ ("@keyframes", "Define a CSS animation", "@keyframes "),
267319 ]
268320 .iter()
269321 .map(|(name, detail, insert)| {
@@ -322,10 +374,17 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
322374 // Identity
323375 ("id", "HTML id attribute", true),
324376 ("class", "HTML class attribute", true),
377+ // Animation
378+ ("animation", "CSS animation (e.g., name 0.3s ease)", true),
325379 // State prefixes
326380 ("hover:", "Style on hover", false),
327381 ("active:", "Style on active/click", false),
328382 ("focus:", "Style on focus", false),
383+ // Responsive prefixes
384+ ("sm:", "Style at 640px+ (small)", false),
385+ ("md:", "Style at 768px+ (medium)", false),
386+ ("lg:", "Style at 1024px+ (large)", false),
387+ ("xl:", "Style at 1280px+ (extra large)", false),
329388 ]
330389 .iter()
331390 .map(|(name, detail, takes_value)| {
@@ -569,6 +628,7 @@ fn hover_builtin(word: &str) -> Option<String> {
569628 "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
570629 "@define" => "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists.",
571630 "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
631+ "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
572632 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
573633 // Attributes
574634 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
@@ -610,6 +670,7 @@ fn hover_builtin(word: &str) -> Option<String> {
610670 "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
611671 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
612672 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
673+ "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
613674 _ => return None,
614675 };
615676
@@ -620,6 +681,300 @@ fn hover_builtin(word: &str) -> Option<String> {
620681 })
621682}
622683
684+// ---------------------------------------------------------------------------
685+// Go to definition
686+// ---------------------------------------------------------------------------
687+
688+fn definition_at(text: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
689+ let lines: Vec<&str> = text.lines().collect();
690+ let line = lines.get(position.line as usize)?;
691+ let col = (position.character as usize).min(line.len());
692+ let word = word_at(line, col)?;
693+
694+ // Find definition location
695+ let (def_line, def_col, def_len) = if word.starts_with('$') {
696+ let name = &word[1..];
697+ find_definition(text, name)?
698+ } else if word.starts_with('@') {
699+ let name = &word[1..];
700+ find_fn_definition(text, name)?
701+ } else {
702+ return None;
703+ };
704+
705+ Some(GotoDefinitionResponse::Scalar(Location {
706+ uri: uri.clone(),
707+ range: Range::new(
708+ Position::new(def_line, def_col),
709+ Position::new(def_line, def_col + def_len),
710+ ),
711+ }))
712+}
713+
714+/// Find @let, @define, or @fn parameter definition for a $name reference.
715+fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
716+ for (i, line) in text.lines().enumerate() {
717+ let trimmed = line.trim();
718+ let offset = (line.len() - trimmed.len()) as u32;
719+
720+ if let Some(rest) = trimmed.strip_prefix("@let ") {
721+ if let Some((n, _)) = rest.trim().split_once(' ') {
722+ if n == name {
723+ let col = offset + "@let ".len() as u32;
724+ return Some((i as u32, col, n.len() as u32));
725+ }
726+ }
727+ }
728+
729+ if let Some(rest) = trimmed.strip_prefix("@define ") {
730+ let rest = rest.trim();
731+ if let Some(bracket) = rest.find('[') {
732+ let n = rest[..bracket].trim();
733+ if n == name {
734+ let col = offset + "@define ".len() as u32;
735+ return Some((i as u32, col, n.len() as u32));
736+ }
737+ }
738+ }
739+
740+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
741+ let parts: Vec<&str> = rest.split_whitespace().collect();
742+ for param in &parts[1..] {
743+ let p = param.strip_prefix('$').unwrap_or(param);
744+ if p == name {
745+ // Find the param position in the line
746+ if let Some(pos) = line.find(param) {
747+ return Some((i as u32, pos as u32, param.len() as u32));
748+ }
749+ }
750+ }
751+ }
752+ }
753+ None
754+}
755+
756+/// Find @fn definition for an @name function call.
757+fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
758+ for (i, line) in text.lines().enumerate() {
759+ let trimmed = line.trim();
760+ let offset = (line.len() - trimmed.len()) as u32;
761+
762+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
763+ let parts: Vec<&str> = rest.split_whitespace().collect();
764+ if parts.first() == Some(&name) {
765+ let col = offset + "@fn ".len() as u32;
766+ return Some((i as u32, col, name.len() as u32));
767+ }
768+ }
769+ }
770+ None
771+}
772+
773+// ---------------------------------------------------------------------------
774+// Rename
775+// ---------------------------------------------------------------------------
776+
777+fn prepare_rename_at(text: &str, position: Position) -> Option<PrepareRenameResponse> {
778+ let lines: Vec<&str> = text.lines().collect();
779+ let line = lines.get(position.line as usize)?;
780+ let col = (position.character as usize).min(line.len());
781+ let word = word_at(line, col)?;
782+
783+ // Only allow renaming $variables and @function calls/definitions
784+ if !word.starts_with('$') && !word.starts_with('@') {
785+ return None;
786+ }
787+
788+ let name = &word[1..];
789+
790+ // Check that the symbol actually has a definition
791+ if word.starts_with('$') {
792+ find_definition(text, name)?;
793+ } else {
794+ find_fn_definition(text, name)?;
795+ }
796+
797+ // Find the range of the word in the line
798+ let bytes = line.as_bytes();
799+ let mut start = col;
800+ while start > 0 && is_word_byte(bytes[start - 1]) {
801+ start -= 1;
802+ }
803+ let mut end = col;
804+ while end < bytes.len() && is_word_byte(bytes[end]) {
805+ end += 1;
806+ }
807+
808+ Some(PrepareRenameResponse::Range(Range::new(
809+ Position::new(position.line, start as u32),
810+ Position::new(position.line, end as u32),
811+ )))
812+}
813+
814+fn rename_at(text: &str, position: Position, new_name: &str, uri: &Url) -> Option<WorkspaceEdit> {
815+ let lines: Vec<&str> = text.lines().collect();
816+ let line = lines.get(position.line as usize)?;
817+ let col = (position.character as usize).min(line.len());
818+ let word = word_at(line, col)?;
819+
820+ let is_var = word.starts_with('$');
821+ let name = &word[1..]; // strip $ or @
822+
823+ // Strip $ or @ from new_name if user included it
824+ let new_base = new_name
825+ .strip_prefix('$')
826+ .or_else(|| new_name.strip_prefix('@'))
827+ .unwrap_or(new_name);
828+
829+ let mut edits = Vec::new();
830+
831+ for (i, line) in text.lines().enumerate() {
832+ let line_num = i as u32;
833+ let trimmed = line.trim();
834+
835+ if is_var {
836+ // Rename @let definition
837+ if let Some(rest) = trimmed.strip_prefix("@let ") {
838+ if let Some((n, _)) = rest.trim().split_once(' ') {
839+ if n == name {
840+ if let Some(pos) = line.find(n) {
841+ edits.push(TextEdit {
842+ range: Range::new(
843+ Position::new(line_num, pos as u32),
844+ Position::new(line_num, (pos + n.len()) as u32),
845+ ),
846+ new_text: new_base.to_string(),
847+ });
848+ }
849+ }
850+ }
851+ }
852+
853+ // Rename @define definition
854+ if let Some(rest) = trimmed.strip_prefix("@define ") {
855+ let rest_trimmed = rest.trim();
856+ if let Some(bracket) = rest_trimmed.find('[') {
857+ let n = rest_trimmed[..bracket].trim();
858+ if n == name {
859+ if let Some(pos) = line.find(n) {
860+ edits.push(TextEdit {
861+ range: Range::new(
862+ Position::new(line_num, pos as u32),
863+ Position::new(line_num, (pos + n.len()) as u32),
864+ ),
865+ new_text: new_base.to_string(),
866+ });
867+ continue; // Don't also match $ references on this line
868+ }
869+ }
870+ }
871+ }
872+
873+ // Rename @fn parameter definitions
874+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
875+ let parts: Vec<&str> = rest.split_whitespace().collect();
876+ for param in &parts[1..] {
877+ let p = param.strip_prefix('$').unwrap_or(param);
878+ if p == name {
879+ if let Some(pos) = line.find(param) {
880+ let prefix = if param.starts_with('$') { "$" } else { "" };
881+ edits.push(TextEdit {
882+ range: Range::new(
883+ Position::new(line_num, pos as u32),
884+ Position::new(line_num, (pos + param.len()) as u32),
885+ ),
886+ new_text: format!("{}{}", prefix, new_base),
887+ });
888+ }
889+ }
890+ }
891+ }
892+
893+ // Rename all $name references
894+ let search = format!("${}", name);
895+ let replace = format!("${}", new_base);
896+ let mut offset = 0;
897+ while let Some(pos) = line[offset..].find(&search) {
898+ let abs_pos = offset + pos;
899+ // Check it's not part of a longer identifier
900+ let after = abs_pos + search.len();
901+ let is_end = after >= line.len()
902+ || !line.as_bytes()[after].is_ascii_alphanumeric()
903+ && line.as_bytes()[after] != b'-'
904+ && line.as_bytes()[after] != b'_';
905+ if is_end {
906+ edits.push(TextEdit {
907+ range: Range::new(
908+ Position::new(line_num, abs_pos as u32),
909+ Position::new(line_num, (abs_pos + search.len()) as u32),
910+ ),
911+ new_text: replace.clone(),
912+ });
913+ }
914+ offset = abs_pos + search.len();
915+ }
916+ } else {
917+ // Rename @fn definition
918+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
919+ let parts: Vec<&str> = rest.split_whitespace().collect();
920+ if parts.first() == Some(&name) {
921+ if let Some(pos) = line.find(&format!("@fn {}", name)) {
922+ let start = pos + 4; // skip "@fn "
923+ edits.push(TextEdit {
924+ range: Range::new(
925+ Position::new(line_num, start as u32),
926+ Position::new(line_num, (start + name.len()) as u32),
927+ ),
928+ new_text: new_base.to_string(),
929+ });
930+ }
931+ }
932+ }
933+
934+ // Rename @name function calls
935+ let search = format!("@{}", name);
936+ let replace = format!("@{}", new_base);
937+ let mut offset = 0;
938+ while let Some(pos) = line[offset..].find(&search) {
939+ let abs_pos = offset + pos;
940+ // Don't match @fn definition (handled above)
941+ if trimmed.starts_with("@fn ") {
942+ offset = abs_pos + search.len();
943+ continue;
944+ }
945+ // Check it's not part of a longer identifier
946+ let after = abs_pos + search.len();
947+ let is_end = after >= line.len()
948+ || !line.as_bytes()[after].is_ascii_alphanumeric()
949+ && line.as_bytes()[after] != b'-'
950+ && line.as_bytes()[after] != b'_';
951+ if is_end {
952+ edits.push(TextEdit {
953+ range: Range::new(
954+ Position::new(line_num, abs_pos as u32),
955+ Position::new(line_num, (abs_pos + search.len()) as u32),
956+ ),
957+ new_text: replace.clone(),
958+ });
959+ }
960+ offset = abs_pos + search.len();
961+ }
962+ }
963+ }
964+
965+ if edits.is_empty() {
966+ return None;
967+ }
968+
969+ let mut changes = HashMap::new();
970+ changes.insert(uri.clone(), edits);
971+
972+ Some(WorkspaceEdit {
973+ changes: Some(changes),
974+ ..Default::default()
975+ })
976+}
977+
623978// ---------------------------------------------------------------------------
624979// Entry point
625980// ---------------------------------------------------------------------------