Compare commits
1 Commits
quickfix
...
wip-abando
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6ae9386a8 |
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
24
crates/assistant_rules/Cargo.toml
Normal file
24
crates/assistant_rules/Cargo.toml
Normal 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
|
||||
1
crates/assistant_rules/LICENSE-GPL
Symbolic link
1
crates/assistant_rules/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
129
crates/assistant_rules/src/assistant_rules.rs
Normal file
129
crates/assistant_rules/src/assistant_rules.rs
Normal 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 }
|
||||
})
|
||||
}
|
||||
230
crates/assistant_rules/src/cursor_mdc.rs
Normal file
230
crates/assistant_rules/src/cursor_mdc.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user