Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Sloan
d6ae9386a8 WIP support for .cursor/rules 2025-03-28 13:01:32 -06:00
6 changed files with 399 additions and 0 deletions

13
Cargo.lock generated
View File

@@ -604,6 +604,19 @@ dependencies = [
"util",
]
[[package]]
name = "assistant_rules"
version = "0.1.0"
dependencies = [
"anyhow",
"fs",
"futures 0.3.31",
"gpui",
"indoc",
"util",
"worktree",
]
[[package]]
name = "assistant_settings"
version = "0.1.0"

View File

@@ -9,6 +9,7 @@ members = [
"crates/assistant2",
"crates/assistant_context_editor",
"crates/assistant_eval",
"crates/assistant_rules",
"crates/assistant_settings",
"crates/assistant_slash_command",
"crates/assistant_slash_commands",
@@ -216,6 +217,7 @@ assistant = { path = "crates/assistant" }
assistant2 = { path = "crates/assistant2" }
assistant_context_editor = { path = "crates/assistant_context_editor" }
assistant_eval = { path = "crates/assistant_eval" }
assistant_rules = { path = "crates/assistant_rules" }
assistant_settings = { path = "crates/assistant_settings" }
assistant_slash_command = { path = "crates/assistant_slash_command" }
assistant_slash_commands = { path = "crates/assistant_slash_commands" }

View File

@@ -0,0 +1,24 @@
[package]
name = "assistant_rules"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/assistant_rules.rs"
doctest = false
[dependencies]
anyhow.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true
util.workspace = true
worktree.workspace = true
[dev-dependencies]
indoc.workspace = true

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,129 @@
use std::{ffi::OsStr, path::Path, sync::Arc};
use anyhow::{anyhow, Context};
use fs::Fs;
use futures::future;
use gpui::{App, AppContext, Task};
use util::maybe;
use worktree::Worktree;
mod cursor_mdc;
#[derive(Debug)]
pub struct LoadRulesResult {
pub files: Vec<RulesFile>,
pub first_error: Option<String>,
}
#[derive(Debug, PartialEq)]
pub struct RulesFile {
pub rel_path: Arc<Path>,
pub abs_path: Arc<Path>,
pub content: RulesContent,
}
#[derive(Debug, PartialEq)]
pub struct RulesContent {
pub when_included: WhenIncluded,
pub description: Option<String>,
pub text: String,
}
// todo! better names
#[derive(Debug, PartialEq)]
pub enum WhenIncluded {
Always,
AutoAttached { globs: Vec<String> },
AgentRequested,
Manual,
}
pub fn load_rules(fs: Arc<dyn Fs>, worktree: &Worktree, cx: &App) -> Task<LoadRulesResult> {
// Note that Cline supports `.clinerules` being a directory, but that is not currently
// supported. This doesn't seem to occur often in GitHub repositories.
const PLAIN_RULES_FILE_NAMES: [&'static str; 6] = [
".rules",
".cursorrules",
".windsurfrules",
".clinerules",
".github/copilot-instructions.md",
"CLAUDE.md",
];
let selected_plain_rules_file = PLAIN_RULES_FILE_NAMES
.into_iter()
.filter_map(|name| {
worktree
.entry_for_path(name)
.filter(|entry| entry.is_file())
.map(|entry| (entry.path.clone(), worktree.absolutize(&entry.path)))
})
.next();
let mut rules_futures = Vec::new();
rules_futures.extend(selected_plain_rules_file.map(|(rel_path, abs_path)| {
let fs = fs.clone();
future::Either::Left(maybe!(async move {
let abs_path = abs_path?;
let text = fs
.load(&abs_path)
.await
.with_context(|| format!("Failed to load assistant rules file {:?}", abs_path))?;
anyhow::Ok(RulesFile {
rel_path,
abs_path: abs_path.into(),
content: RulesContent {
when_included: WhenIncluded::Always,
description: None,
text: text.trim().to_string(),
},
})
}))
}));
// todo! Does this already recurse?
let mdc_rules_path = Path::new(".cursor/rules");
let mdc_extension = OsStr::new("mdc");
rules_futures.extend(
worktree
.child_entries(mdc_rules_path)
.filter(|entry| entry.is_file() && entry.path.extension() == Some(mdc_extension))
.map(|entry| {
let fs = fs.clone();
let rel_path = entry.path.clone();
let abs_path = worktree.absolutize(&rel_path);
future::Either::Right(maybe!(async move {
let abs_path = abs_path?;
let text = fs.load(&abs_path).await.with_context(|| {
format!("Failed to load assistant rules file {:?}", abs_path)
})?;
match cursor_mdc::parse(&text) {
Ok(content) => anyhow::Ok(RulesFile {
rel_path: rel_path,
abs_path: abs_path.into(),
content,
}),
Err(cursor_mdc::ParseError::MissingFrontmatter) => Err(anyhow!("todo!")),
}
}))
}),
);
cx.background_spawn(async move {
let results = future::join_all(rules_futures).await;
let mut first_error = None;
let files = results
.into_iter()
.filter_map(|result| match result {
Ok(file) => Some(file),
Err(err) => {
if first_error.is_none() {
first_error = Some(err.to_string());
}
None
}
})
.collect::<Vec<_>>();
LoadRulesResult { files, first_error }
})
}

View File

@@ -0,0 +1,230 @@
use crate::{RulesContent, WhenIncluded};
use std::ops::Range;
#[derive(Debug, PartialEq)]
pub enum ParseError {
MissingFrontmatter,
}
/// Parses Cursor *.mdc rule files which consist of frontmatter followed by content. While the
/// frontmatter looks like YAML, it is not. Instead each field is on a single line and has no
/// escaping rules (there is no way to have a file glob that involves ","), and there are no
/// parse errors.
pub fn parse(source: &str) -> Result<RulesContent, ParseError> {
let mut line_ranges = line_ranges(source);
let first_line_range = line_ranges.next().unwrap();
if !is_delimiter_line(source, first_line_range.clone()) {
return Err(ParseError::MissingFrontmatter);
}
let mut description = None;
let mut globs = None;
let mut always_apply = false;
let mut text_start = None;
for line_range in line_ranges {
if is_delimiter_line(source, line_range.clone()) {
text_start = Some(line_range.end + 1);
break;
}
let line = source[line_range].trim_start();
if let Some(value) = parse_field(line, "description") {
description = Some(value.to_string());
}
if let Some(value) = parse_field(line, "globs") {
globs = Some(value);
}
if let Some(value) = parse_field(line, "alwaysApply") {
always_apply = value == "true";
}
}
let Some(text_start) = text_start else {
return Err(ParseError::MissingFrontmatter);
};
let when_included = if always_apply {
WhenIncluded::Always
} else if let Some(globs) = globs {
// These are not trimmed as they do actually match spaces, even though the Cursor UI doesn't
// allow entering spaces.
WhenIncluded::AutoAttached {
globs: globs
.split(',')
.map(|glob| glob.to_string())
.collect::<Vec<_>>(),
}
} else if description.is_some() {
WhenIncluded::AgentRequested
} else {
WhenIncluded::Manual
};
let text = source.get(text_start..).unwrap_or_default().to_string();
Ok(RulesContent {
when_included,
description,
text,
})
}
fn parse_field<'a>(line: &'a str, name: &'a str) -> Option<&'a str> {
line.strip_prefix(name)
.and_then(|suffix| suffix.trim().strip_prefix(":").map(str::trim))
.filter(|value| !value.is_empty())
}
fn is_delimiter_line(source: &str, line_range: Range<usize>) -> bool {
const FRONTMATTER_DELIMITER: &str = "---";
line_range.end - line_range.start >= FRONTMATTER_DELIMITER.len()
&& &source[line_range.start..line_range.start + FRONTMATTER_DELIMITER.len()]
== FRONTMATTER_DELIMITER
&& source[line_range.start + FRONTMATTER_DELIMITER.len()..line_range.end]
.chars()
.all(char::is_whitespace)
}
fn line_ranges(text: &str) -> impl Iterator<Item = Range<usize>> + '_ {
let mut line_start = 0;
text.match_indices('\n')
.map(move |(offset, _)| {
let range = line_start..offset;
line_start = offset + 1;
range
})
.chain(
std::iter::once_with(move || {
if line_start < text.len() || line_start == 0 {
Some(line_start..text.len())
} else {
None
}
})
.flatten(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
#[test]
fn test_parse_always_included() {
let text = indoc! {r#"
---
description: some description
globs: *.rs
alwaysApply: true
---
Rule body text"#};
let rule = parse(text).unwrap();
assert_eq!(
rule,
RulesContent {
when_included: WhenIncluded::Always,
description: Some("some description".into()),
text: "Rule body text".into(),
}
);
}
#[test]
fn test_parse_auto_attached() {
let text = indoc! {r#"
---
globs: *.rs, spaces in glob ,*.md
---
Rule body text"#};
let rule = parse(text).unwrap();
assert_eq!(
rule,
RulesContent {
when_included: WhenIncluded::AutoAttached {
globs: vec!["*.rs".into(), " spaces in glob ".into(), "*.md".into()]
},
description: None,
text: "Rule body text".into(),
}
);
}
#[test]
fn test_parse_rule_type_agent_requested() {
let text = indoc! {r#"
---
description: some description
---
Rule body text"#};
let rule = parse(text).unwrap();
assert_eq!(
rule,
RulesContent {
when_included: WhenIncluded::AgentRequested,
description: Some("some description".into()),
text: "Rule body text".into(),
}
);
}
#[test]
fn test_parse_rule_type_manual() {
let text = indoc! {r#"
---
alwaysApply: false
---
Rule body text"#};
let rule = parse(text).unwrap();
assert_eq!(
rule,
RulesContent {
when_included: WhenIncluded::Manual,
description: None,
text: "Rule body text".into(),
}
);
}
#[test]
fn test_parse_rule_whitespace() {
// Experimentally, spaces are allowed before fields and ":", but not before "---".
let text = indoc! {r#"
---
globs : *.rs
description : some description
---
Rule body text"#};
let rule = parse(text).unwrap();
assert_eq!(
rule,
RulesContent {
when_included: WhenIncluded::AutoAttached {
globs: vec!["*.rs".into()]
},
description: Some("some description".into()),
text: "Rule body text".into(),
}
);
}
#[test]
fn test_parse_no_frontmatter() {
let text = "Invalid content without frontmatter";
assert_eq!(parse(text), Err(ParseError::MissingFrontmatter));
}
#[test]
fn test_parse_invalid_end_delimeter() {
let text = indoc! {r#"
---
description : some description
---
Invalid end delimeter
"#};
assert_eq!(parse(text), Err(ParseError::MissingFrontmatter));
}
}