Reorganize edit prediction code and remove old experiments (#44187)

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Max Brunsfeld
2025-12-04 15:56:57 -08:00
committed by GitHub
co-authored by Agus Zubiaga Ben Kunkle
parent 76167109db
commit 42583c1141
113 changed files with 5598 additions and 15080 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,843 @@
use std::{
collections::BTreeSet,
fmt::{Display, Formatter},
ops::Range,
path::PathBuf,
sync::{Arc, LazyLock},
};
use anyhow::{Result, anyhow};
use fs::Fs;
use futures::StreamExt as _;
use gpui::{App, AppContext as _, Entity, Subscription, Task};
use itertools::Itertools;
use postage::watch;
use project::Worktree;
use strum::VariantArray;
use util::{ResultExt as _, maybe, rel_path::RelPath};
use worktree::ChildEntriesOptions;
/// Matches the most common license locations, with US and UK English spelling.
static LICENSE_FILE_NAME_REGEX: LazyLock<regex::bytes::Regex> = LazyLock::new(|| {
regex::bytes::RegexBuilder::new(
"^ \
(?: license | licence)? \
(?: [\\-._]? \
(?: apache (?: [\\-._] (?: 2.0 | 2 ))? | \
0? bsd (?: [\\-._] [0123])? (?: [\\-._] clause)? | \
isc | \
mit | \
upl | \
zlib))? \
(?: [\\-._]? (?: license | licence))? \
(?: \\.txt | \\.md)? \
$",
)
.ignore_whitespace(true)
.case_insensitive(true)
.build()
.unwrap()
});
#[derive(Debug, Clone, Copy, Eq, Ord, PartialOrd, PartialEq, VariantArray)]
pub enum OpenSourceLicense {
Apache2_0,
BSDZero,
BSD,
ISC,
MIT,
UPL1_0,
Zlib,
}
impl Display for OpenSourceLicense {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.spdx_identifier())
}
}
impl OpenSourceLicense {
/// These are SPDX identifiers for the licenses, except for BSD, where the variants are not
/// distinguished.
pub fn spdx_identifier(&self) -> &'static str {
match self {
OpenSourceLicense::Apache2_0 => "apache-2.0",
OpenSourceLicense::BSDZero => "0bsd",
OpenSourceLicense::BSD => "bsd",
OpenSourceLicense::ISC => "isc",
OpenSourceLicense::MIT => "mit",
OpenSourceLicense::UPL1_0 => "upl-1.0",
OpenSourceLicense::Zlib => "zlib",
}
}
pub fn patterns(&self) -> &'static [&'static str] {
match self {
OpenSourceLicense::Apache2_0 => &[
include_str!("../license_patterns/apache-2.0-pattern"),
include_str!("../license_patterns/apache-2.0-reference-pattern"),
],
OpenSourceLicense::BSDZero => &[include_str!("../license_patterns/0bsd-pattern")],
OpenSourceLicense::BSD => &[include_str!("../license_patterns/bsd-pattern")],
OpenSourceLicense::ISC => &[include_str!("../license_patterns/isc-pattern")],
OpenSourceLicense::MIT => &[include_str!("../license_patterns/mit-pattern")],
OpenSourceLicense::UPL1_0 => &[include_str!("../license_patterns/upl-1.0-pattern")],
OpenSourceLicense::Zlib => &[include_str!("../license_patterns/zlib-pattern")],
}
}
}
// TODO: Consider using databake or similar to not parse at runtime.
static LICENSE_PATTERNS: LazyLock<LicensePatterns> = LazyLock::new(|| {
let mut approximate_max_length = 0;
let mut patterns = Vec::new();
for license in OpenSourceLicense::VARIANTS {
for pattern in license.patterns() {
let (pattern, length) = parse_pattern(pattern).unwrap();
patterns.push((*license, pattern));
approximate_max_length = approximate_max_length.max(length);
}
}
LicensePatterns {
patterns,
approximate_max_length,
}
});
fn detect_license(text: &str) -> Option<OpenSourceLicense> {
let text = canonicalize_license_text(text);
for (license, pattern) in LICENSE_PATTERNS.patterns.iter() {
log::trace!("Checking if license is {}", license);
if check_pattern(&pattern, &text) {
return Some(*license);
}
}
None
}
struct LicensePatterns {
patterns: Vec<(OpenSourceLicense, Vec<PatternPart>)>,
approximate_max_length: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct PatternPart {
/// Indicates that matching `text` is optional. Skipping `match_any_chars` is conditional on
/// matching `text`.
optional: bool,
/// Indicates the number of characters that can be skipped before matching `text`.
match_any_chars: Range<usize>,
/// The text to match, may be empty.
text: String,
}
/// Lines that start with "-- " begin a `PatternPart`. `-- 1..10` specifies `match_any_chars:
/// 1..10`. `-- 1..10 optional:` additionally specifies `optional: true`. It's a parse error for a
/// line to start with `--` without matching this format.
///
/// Text that does not have `--` prefixes participate in the `text` field and are canonicalized by
/// lowercasing, replacing all runs of whitespace with a single space, and otherwise only keeping
/// ascii alphanumeric characters.
fn parse_pattern(pattern_source: &str) -> Result<(Vec<PatternPart>, usize)> {
let mut pattern = Vec::new();
let mut part = PatternPart::default();
let mut approximate_max_length = 0;
for line in pattern_source.lines() {
if let Some(directive) = line.trim().strip_prefix("--") {
if part != PatternPart::default() {
pattern.push(part);
part = PatternPart::default();
}
let valid = maybe!({
let directive_chunks = directive.split_whitespace().collect::<Vec<_>>();
if !(1..=2).contains(&directive_chunks.len()) {
return None;
}
if directive_chunks.len() == 2 {
part.optional = true;
}
let range_chunks = directive_chunks[0].split("..").collect::<Vec<_>>();
if range_chunks.len() != 2 {
return None;
}
part.match_any_chars.start = range_chunks[0].parse::<usize>().ok()?;
part.match_any_chars.end = range_chunks[1].parse::<usize>().ok()?;
if part.match_any_chars.start > part.match_any_chars.end {
return None;
}
approximate_max_length += part.match_any_chars.end;
Some(())
});
if valid.is_none() {
return Err(anyhow!("Invalid pattern directive: {}", line));
}
continue;
}
approximate_max_length += line.len() + 1;
let line = canonicalize_license_text(line);
if line.is_empty() {
continue;
}
if !part.text.is_empty() {
part.text.push(' ');
}
part.text.push_str(&line);
}
if part != PatternPart::default() {
pattern.push(part);
}
Ok((pattern, approximate_max_length))
}
/// Checks a pattern against text by iterating over the pattern parts in reverse order, and checking
/// matches with the end of a prefix of the input. Assumes that `canonicalize_license_text` has
/// already been applied to the input.
fn check_pattern(pattern: &[PatternPart], input: &str) -> bool {
let mut input_ix = input.len();
let mut match_any_chars = 0..0;
for part in pattern.iter().rev() {
if part.text.is_empty() {
match_any_chars.start += part.match_any_chars.start;
match_any_chars.end += part.match_any_chars.end;
continue;
}
let search_range_end = n_chars_before_offset(match_any_chars.start, input_ix, input);
let search_range_start = n_chars_before_offset(
match_any_chars.len() + part.text.len(),
search_range_end,
input,
);
let found_ix = input[search_range_start..search_range_end].rfind(&part.text);
if let Some(found_ix) = found_ix {
input_ix = search_range_start + found_ix;
match_any_chars = part.match_any_chars.clone();
} else if !part.optional {
log::trace!(
"Failed to match pattern\n`...{}`\nagainst input\n`...{}`",
&part.text[n_chars_before_offset(128, part.text.len(), &part.text)..],
&input[n_chars_before_offset(128, search_range_end, input)..search_range_end],
);
return false;
}
}
is_char_count_within_range(&input[..input_ix], match_any_chars)
}
fn n_chars_before_offset(char_count: usize, offset: usize, string: &str) -> usize {
if char_count == 0 {
return offset;
}
string[..offset]
.char_indices()
.nth_back(char_count.saturating_sub(1))
.map_or(0, |(byte_ix, _)| byte_ix)
}
fn is_char_count_within_range(string: &str, char_count_range: Range<usize>) -> bool {
if string.len() >= char_count_range.start * 4 && string.len() < char_count_range.end {
return true;
}
if string.len() < char_count_range.start || string.len() >= char_count_range.end * 4 {
return false;
}
char_count_range.contains(&string.chars().count())
}
/// Canonicalizes license text by removing all non-alphanumeric characters, lowercasing, and turning
/// runs of whitespace into a single space. Unicode alphanumeric characters are intentionally
/// preserved since these should cause license mismatch when not within a portion of the license
/// where arbitrary text is allowed.
fn canonicalize_license_text(license: &str) -> String {
license
.chars()
.filter(|c| c.is_ascii_whitespace() || c.is_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect::<String>()
.split_ascii_whitespace()
.join(" ")
}
pub enum LicenseDetectionWatcher {
Local {
is_open_source_rx: watch::Receiver<bool>,
_is_open_source_task: Task<()>,
_worktree_subscription: Subscription,
},
SingleFile,
Remote,
}
impl LicenseDetectionWatcher {
pub fn new(worktree: &Entity<Worktree>, cx: &mut App) -> Self {
let worktree_ref = worktree.read(cx);
if worktree_ref.is_single_file() {
return Self::SingleFile;
}
let (files_to_check_tx, mut files_to_check_rx) = futures::channel::mpsc::unbounded();
let Worktree::Local(local_worktree) = worktree_ref else {
return Self::Remote;
};
let fs = local_worktree.fs().clone();
let options = ChildEntriesOptions {
include_files: true,
include_dirs: false,
include_ignored: true,
};
for top_file in local_worktree.child_entries_with_options(RelPath::empty(), options) {
let path_bytes = top_file.path.as_unix_str().as_bytes();
if top_file.is_created() && LICENSE_FILE_NAME_REGEX.is_match(path_bytes) {
let rel_path = top_file.path.clone();
files_to_check_tx.unbounded_send(rel_path).ok();
}
}
let _worktree_subscription =
cx.subscribe(worktree, move |_worktree, event, _cx| match event {
worktree::Event::UpdatedEntries(updated_entries) => {
for updated_entry in updated_entries.iter() {
let rel_path = &updated_entry.0;
let path_bytes = rel_path.as_unix_str().as_bytes();
if LICENSE_FILE_NAME_REGEX.is_match(path_bytes) {
files_to_check_tx.unbounded_send(rel_path.clone()).ok();
}
}
}
worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) => {}
});
let worktree_snapshot = worktree.read(cx).snapshot();
let (mut is_open_source_tx, is_open_source_rx) = watch::channel_with::<bool>(false);
let _is_open_source_task = cx.background_spawn(async move {
let mut eligible_licenses = BTreeSet::new();
while let Some(rel_path) = files_to_check_rx.next().await {
let abs_path = worktree_snapshot.absolutize(&rel_path);
let was_open_source = !eligible_licenses.is_empty();
if Self::is_path_eligible(&fs, abs_path).await.unwrap_or(false) {
eligible_licenses.insert(rel_path);
} else {
eligible_licenses.remove(&rel_path);
}
let is_open_source = !eligible_licenses.is_empty();
if is_open_source != was_open_source {
*is_open_source_tx.borrow_mut() = is_open_source;
}
}
});
Self::Local {
is_open_source_rx,
_is_open_source_task,
_worktree_subscription,
}
}
async fn is_path_eligible(fs: &Arc<dyn Fs>, abs_path: PathBuf) -> Option<bool> {
log::debug!("checking if `{abs_path:?}` is an open source license");
// resolve symlinks so that the file size from metadata is correct
let Some(abs_path) = fs.canonicalize(&abs_path).await.ok() else {
log::debug!(
"`{abs_path:?}` license file probably deleted (error canonicalizing the path)"
);
return None;
};
let metadata = fs.metadata(&abs_path).await.log_err()??;
if metadata.len > LICENSE_PATTERNS.approximate_max_length as u64 {
log::debug!(
"`{abs_path:?}` license file was skipped \
because its size of {} bytes was larger than the max size of {} bytes",
metadata.len,
LICENSE_PATTERNS.approximate_max_length
);
return None;
}
let text = fs.load(&abs_path).await.log_err()?;
let is_eligible = detect_license(&text).is_some();
if is_eligible {
log::debug!(
"`{abs_path:?}` matches a license that is eligible for data collection (if enabled)"
);
} else {
log::debug!(
"`{abs_path:?}` does not match a license that is eligible for data collection"
);
}
Some(is_eligible)
}
/// Answers false until we find out it's open source
pub fn is_project_open_source(&self) -> bool {
match self {
Self::Local {
is_open_source_rx, ..
} => *is_open_source_rx.borrow(),
Self::SingleFile | Self::Remote => false,
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use fs::FakeFs;
use gpui::TestAppContext;
use rand::Rng as _;
use serde_json::json;
use settings::SettingsStore;
use super::*;
const APACHE_2_0_TXT: &str = include_str!("../license_examples/apache-2.0-ex0.txt");
const ISC_TXT: &str = include_str!("../license_examples/isc.txt");
const MIT_TXT: &str = include_str!("../license_examples/mit-ex0.txt");
const UPL_1_0_TXT: &str = include_str!("../license_examples/upl-1.0.txt");
const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt");
#[track_caller]
fn assert_matches_license(text: &str, license: OpenSourceLicense) {
assert_eq!(detect_license(text), Some(license));
assert!(text.len() < LICENSE_PATTERNS.approximate_max_length);
}
/*
// Uncomment this and run with `cargo test -p zeta -- --no-capture &> licenses-output` to
// traverse your entire home directory and run license detection on every file that has a
// license-like name.
#[test]
fn test_check_all_licenses_in_home_dir() {
let mut detected = Vec::new();
let mut unrecognized = Vec::new();
let mut walked_entries = 0;
let homedir = std::env::home_dir().unwrap();
for entry in walkdir::WalkDir::new(&homedir) {
walked_entries += 1;
if walked_entries % 10000 == 0 {
println!(
"So far visited {} files in {}",
walked_entries,
homedir.display()
);
}
let Ok(entry) = entry else {
continue;
};
if !LICENSE_FILE_NAME_REGEX.is_match(entry.file_name().as_encoded_bytes()) {
continue;
}
let Ok(contents) = std::fs::read_to_string(entry.path()) else {
continue;
};
let path_string = entry.path().to_string_lossy().into_owned();
let license = detect_license(&contents);
match license {
Some(license) => detected.push((license, path_string)),
None => unrecognized.push(path_string),
}
}
println!("\nDetected licenses:\n");
detected.sort();
for (license, path) in &detected {
println!("{}: {}", license.spdx_identifier(), path);
}
println!("\nUnrecognized licenses:\n");
for path in &unrecognized {
println!("{}", path);
}
panic!(
"{} licenses detected, {} unrecognized",
detected.len(),
unrecognized.len()
);
println!("This line has a warning to make sure this test is always commented out");
}
*/
#[test]
fn test_apache_positive_detection() {
assert_matches_license(APACHE_2_0_TXT, OpenSourceLicense::Apache2_0);
assert_matches_license(
include_str!("../license_examples/apache-2.0-ex1.txt"),
OpenSourceLicense::Apache2_0,
);
assert_matches_license(
include_str!("../license_examples/apache-2.0-ex2.txt"),
OpenSourceLicense::Apache2_0,
);
assert_matches_license(
include_str!("../license_examples/apache-2.0-ex3.txt"),
OpenSourceLicense::Apache2_0,
);
assert_matches_license(
include_str!("../license_examples/apache-2.0-ex4.txt"),
OpenSourceLicense::Apache2_0,
);
assert_matches_license(
include_str!("../../../LICENSE-APACHE"),
OpenSourceLicense::Apache2_0,
);
}
#[test]
fn test_apache_negative_detection() {
assert_eq!(
detect_license(&format!(
"{APACHE_2_0_TXT}\n\nThe terms in this license are void if P=NP."
)),
None
);
}
#[test]
fn test_bsd_1_clause_positive_detection() {
assert_matches_license(
include_str!("../license_examples/bsd-1-clause.txt"),
OpenSourceLicense::BSD,
);
}
#[test]
fn test_bsd_2_clause_positive_detection() {
assert_matches_license(
include_str!("../license_examples/bsd-2-clause-ex0.txt"),
OpenSourceLicense::BSD,
);
}
#[test]
fn test_bsd_3_clause_positive_detection() {
assert_matches_license(
include_str!("../license_examples/bsd-3-clause-ex0.txt"),
OpenSourceLicense::BSD,
);
assert_matches_license(
include_str!("../license_examples/bsd-3-clause-ex1.txt"),
OpenSourceLicense::BSD,
);
assert_matches_license(
include_str!("../license_examples/bsd-3-clause-ex2.txt"),
OpenSourceLicense::BSD,
);
assert_matches_license(
include_str!("../license_examples/bsd-3-clause-ex3.txt"),
OpenSourceLicense::BSD,
);
assert_matches_license(
include_str!("../license_examples/bsd-3-clause-ex4.txt"),
OpenSourceLicense::BSD,
);
}
#[test]
fn test_bsd_0_positive_detection() {
assert_matches_license(BSD_0_TXT, OpenSourceLicense::BSDZero);
}
#[test]
fn test_isc_positive_detection() {
assert_matches_license(ISC_TXT, OpenSourceLicense::ISC);
}
#[test]
fn test_isc_negative_detection() {
let license_text = format!(
r#"{ISC_TXT}
This project is dual licensed under the ISC License and the MIT License."#
);
assert_eq!(detect_license(&license_text), None);
}
#[test]
fn test_mit_positive_detection() {
assert_matches_license(MIT_TXT, OpenSourceLicense::MIT);
assert_matches_license(
include_str!("../license_examples/mit-ex1.txt"),
OpenSourceLicense::MIT,
);
assert_matches_license(
include_str!("../license_examples/mit-ex2.txt"),
OpenSourceLicense::MIT,
);
assert_matches_license(
include_str!("../license_examples/mit-ex3.txt"),
OpenSourceLicense::MIT,
);
}
#[test]
fn test_mit_negative_detection() {
let license_text = format!(
r#"{MIT_TXT}
This project is dual licensed under the MIT License and the Apache License, Version 2.0."#
);
assert_eq!(detect_license(&license_text), None);
}
#[test]
fn test_upl_positive_detection() {
assert_matches_license(UPL_1_0_TXT, OpenSourceLicense::UPL1_0);
}
#[test]
fn test_upl_negative_detection() {
let license_text = format!(
r#"{UPL_1_0_TXT}
This project is dual licensed under the UPL License and the MIT License."#
);
assert_eq!(detect_license(&license_text), None);
}
#[test]
fn test_zlib_positive_detection() {
assert_matches_license(
include_str!("../license_examples/zlib-ex0.txt"),
OpenSourceLicense::Zlib,
);
}
#[test]
fn random_strings_negative_detection() {
for _i in 0..20 {
let random_string = rand::rng()
.sample_iter::<char, _>(rand::distr::StandardUniform)
.take(512)
.collect::<String>();
assert_eq!(detect_license(&random_string), None);
}
}
#[test]
fn test_n_chars_before_offset() {
assert_eq!(n_chars_before_offset(2, 4, "hello"), 2);
let input = "ㄒ乇丂ㄒ";
assert_eq!(n_chars_before_offset(2, input.len(), input), "ㄒ乇".len());
}
#[test]
fn test_is_char_count_within_range() {
// TODO: make this into a proper property test.
for _i in 0..20 {
let mut rng = rand::rng();
let random_char_count = rng.random_range(0..64);
let random_string = rand::rng()
.sample_iter::<char, _>(rand::distr::StandardUniform)
.take(random_char_count)
.collect::<String>();
let min_chars = rng.random_range(0..10);
let max_chars = rng.random_range(min_chars..32);
let char_count_range = min_chars..max_chars;
assert_eq!(
is_char_count_within_range(&random_string, char_count_range.clone()),
char_count_range.contains(&random_char_count),
);
}
}
#[test]
fn test_license_file_name_regex() {
// Test basic license file names
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence"));
// Test with extensions
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.txt"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.md"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.txt"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.md"));
// Test with specific license types
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-APACHE"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.MIT"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE_MIT"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-ISC"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-UPL"));
// Test with "license" coming after
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-LICENSE"));
// Test version numbers
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-2"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-2.0"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-1"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-2"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-3"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-3-CLAUSE"));
// Test combinations
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT.txt"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.ISC.md"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license_upl"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.APACHE.2.0"));
// Test case insensitive
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"License"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license-mit.TXT"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE_isc.MD"));
// Test edge cases that should match
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license.mit"));
assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence-upl.txt"));
// Test non-matching patterns
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"COPYING"));
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.html"));
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"MYLICENSE"));
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"src/LICENSE"));
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.old"));
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-GPL"));
assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSEABC"));
}
#[test]
fn test_canonicalize_license_text() {
let input = " Paragraph 1\nwith multiple lines\n\n\n\nParagraph 2\nwith more lines\n ";
let expected = "paragraph 1 with multiple lines paragraph 2 with more lines";
assert_eq!(canonicalize_license_text(input), expected);
// Test tabs and mixed whitespace
let input = "Word1\t\tWord2\n\n Word3\r\n\r\n\r\nWord4 ";
let expected = "word1 word2 word3 word4";
assert_eq!(canonicalize_license_text(input), expected);
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_watcher_single_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" }))
.await;
let worktree = Worktree::local(
Path::new("/root/main.rs"),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
assert!(matches!(watcher, LicenseDetectionWatcher::SingleFile));
assert!(!watcher.is_project_open_source());
}
#[gpui::test]
async fn test_watcher_updates_on_changes(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" }))
.await;
let worktree = Worktree::local(
Path::new("/root"),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. }));
assert!(!watcher.is_project_open_source());
fs.write(Path::new("/root/LICENSE-MIT"), MIT_TXT.as_bytes())
.await
.unwrap();
cx.background_executor.run_until_parked();
assert!(watcher.is_project_open_source());
fs.write(Path::new("/root/LICENSE-APACHE"), APACHE_2_0_TXT.as_bytes())
.await
.unwrap();
cx.background_executor.run_until_parked();
assert!(watcher.is_project_open_source());
fs.write(Path::new("/root/LICENSE-MIT"), "Nevermind".as_bytes())
.await
.unwrap();
// Still considered open source as LICENSE-APACHE is present
cx.background_executor.run_until_parked();
assert!(watcher.is_project_open_source());
fs.write(
Path::new("/root/LICENSE-APACHE"),
"Also nevermind".as_bytes(),
)
.await
.unwrap();
cx.background_executor.run_until_parked();
assert!(!watcher.is_project_open_source());
}
#[gpui::test]
async fn test_watcher_initially_opensource_and_then_deleted(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root",
json!({ "main.rs": "fn main() {}", "LICENSE-MIT": MIT_TXT }),
)
.await;
let worktree = Worktree::local(
Path::new("/root"),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. }));
cx.background_executor.run_until_parked();
assert!(watcher.is_project_open_source());
fs.remove_file(
Path::new("/root/LICENSE-MIT"),
fs::RemoveOptions {
recursive: false,
ignore_if_not_exists: false,
},
)
.await
.unwrap();
cx.background_executor.run_until_parked();
assert!(!watcher.is_project_open_source());
}
}
@@ -0,0 +1,174 @@
use std::sync::Arc;
use crate::ZedPredictUpsell;
use ai_onboarding::EditPredictionOnboarding;
use client::{Client, UserStore};
use db::kvp::Dismissable;
use fs::Fs;
use gpui::{
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
linear_color_stop, linear_gradient,
};
use language::language_settings::EditPredictionProvider;
use settings::update_settings_file;
use ui::{Vector, VectorName, prelude::*};
use workspace::{ModalView, Workspace};
#[macro_export]
macro_rules! onboarding_event {
($name:expr) => {
telemetry::event!($name, source = "Edit Prediction Onboarding");
};
($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
telemetry::event!($name, source = "Edit Prediction Onboarding", $($key $(= $value)?),+);
};
}
/// Introduces user to Zed's Edit Prediction feature
pub struct ZedPredictModal {
onboarding: Entity<EditPredictionOnboarding>,
focus_handle: FocusHandle,
}
pub(crate) fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
settings
.project
.all_languages
.features
.get_or_insert(Default::default())
.edit_prediction_provider = Some(provider);
});
}
impl ZedPredictModal {
pub fn toggle(
workspace: &mut Workspace,
user_store: Entity<UserStore>,
client: Arc<Client>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
workspace.toggle_modal(window, cx, |_window, cx| {
let weak_entity = cx.weak_entity();
Self {
onboarding: cx.new(|cx| {
EditPredictionOnboarding::new(
user_store.clone(),
client.clone(),
copilot::Copilot::global(cx)
.is_some_and(|copilot| copilot.read(cx).status().is_configured()),
Arc::new({
let this = weak_entity.clone();
move |_window, cx| {
ZedPredictUpsell::set_dismissed(true, cx);
set_edit_prediction_provider(EditPredictionProvider::Zed, cx);
this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
}
}),
Arc::new({
let this = weak_entity.clone();
move |window, cx| {
ZedPredictUpsell::set_dismissed(true, cx);
set_edit_prediction_provider(EditPredictionProvider::Copilot, cx);
this.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
copilot::initiate_sign_in(window, cx);
}
}),
cx,
)
}),
focus_handle: cx.focus_handle(),
}
});
}
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
ZedPredictUpsell::set_dismissed(true, cx);
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ZedPredictModal {}
impl Focusable for ZedPredictModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for ZedPredictModal {
fn on_before_dismiss(
&mut self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> workspace::DismissDecision {
ZedPredictUpsell::set_dismissed(true, cx);
workspace::DismissDecision::Dismiss(true)
}
}
impl Render for ZedPredictModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let window_height = window.viewport_size().height;
let max_height = window_height - px(200.);
v_flex()
.id("edit-prediction-onboarding")
.key_context("ZedPredictModal")
.relative()
.w(px(550.))
.h_full()
.max_h(max_height)
.p_4()
.gap_2()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.overflow_hidden()
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
onboarding_event!("Cancelled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
}))
.child(
div()
.opacity(0.5)
.absolute()
.top(px(-8.0))
.right_0()
.w(px(400.))
.h(px(92.))
.child(
Vector::new(VectorName::AiGrid, rems_from_px(400.), rems_from_px(92.))
.color(Color::Custom(cx.theme().colors().text.alpha(0.32))),
),
)
.child(
div()
.absolute()
.top_0()
.right_0()
.w(px(660.))
.h(px(401.))
.overflow_hidden()
.bg(linear_gradient(
75.,
linear_color_stop(cx.theme().colors().panel_background.alpha(0.01), 1.0),
linear_color_stop(cx.theme().colors().panel_background, 0.45),
)),
)
.child(h_flex().absolute().top_2().right_2().child(
IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
onboarding_event!("Cancelled", trigger = "X click");
cx.emit(DismissEvent);
},
)),
))
.child(self.onboarding.clone())
}
}
+333
View File
@@ -0,0 +1,333 @@
use std::{
ops::Range,
path::Path,
sync::Arc,
time::{Duration, Instant},
};
use cloud_llm_client::EditPredictionRejectReason;
use gpui::{AsyncApp, Entity, SharedString};
use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, TextBufferSnapshot};
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Eq, Hash)]
pub struct EditPredictionId(pub SharedString);
impl From<EditPredictionId> for gpui::ElementId {
fn from(value: EditPredictionId) -> Self {
gpui::ElementId::Name(value.0)
}
}
impl std::fmt::Display for EditPredictionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// A prediction response that was returned from the provider, whether it was ultimately valid or not.
pub struct EditPredictionResult {
pub id: EditPredictionId,
pub prediction: Result<EditPrediction, EditPredictionRejectReason>,
}
impl EditPredictionResult {
pub async fn new(
id: EditPredictionId,
edited_buffer: &Entity<Buffer>,
edited_buffer_snapshot: &BufferSnapshot,
edits: Arc<[(Range<Anchor>, Arc<str>)]>,
buffer_snapshotted_at: Instant,
response_received_at: Instant,
inputs: EditPredictionInputs,
cx: &mut AsyncApp,
) -> Self {
if edits.is_empty() {
return Self {
id,
prediction: Err(EditPredictionRejectReason::Empty),
};
}
let Some((edits, snapshot, edit_preview_task)) = edited_buffer
.read_with(cx, |buffer, cx| {
let new_snapshot = buffer.snapshot();
let edits: Arc<[_]> =
interpolate_edits(&edited_buffer_snapshot, &new_snapshot, edits)?.into();
Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx)))
})
.ok()
.flatten()
else {
return Self {
id,
prediction: Err(EditPredictionRejectReason::InterpolatedEmpty),
};
};
let edit_preview = edit_preview_task.await;
Self {
id: id.clone(),
prediction: Ok(EditPrediction {
id,
edits,
snapshot,
edit_preview,
inputs,
buffer: edited_buffer.clone(),
buffer_snapshotted_at,
response_received_at,
}),
}
}
}
#[derive(Clone)]
pub struct EditPrediction {
pub id: EditPredictionId,
pub edits: Arc<[(Range<Anchor>, Arc<str>)]>,
pub snapshot: BufferSnapshot,
pub edit_preview: EditPreview,
pub buffer: Entity<Buffer>,
pub buffer_snapshotted_at: Instant,
pub response_received_at: Instant,
pub inputs: EditPredictionInputs,
}
#[derive(Debug, Clone, Serialize)]
pub struct EditPredictionInputs {
pub events: Vec<Arc<cloud_llm_client::predict_edits_v3::Event>>,
pub included_files: Vec<cloud_llm_client::predict_edits_v3::RelatedFile>,
pub cursor_point: cloud_llm_client::predict_edits_v3::Point,
pub cursor_path: Arc<Path>,
}
impl EditPrediction {
pub fn interpolate(
&self,
new_snapshot: &TextBufferSnapshot,
) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
interpolate_edits(&self.snapshot, new_snapshot, self.edits.clone())
}
pub fn targets_buffer(&self, buffer: &Buffer) -> bool {
self.snapshot.remote_id() == buffer.remote_id()
}
pub fn latency(&self) -> Duration {
self.response_received_at - self.buffer_snapshotted_at
}
}
impl std::fmt::Debug for EditPrediction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EditPrediction")
.field("id", &self.id)
.field("edits", &self.edits)
.finish()
}
}
pub fn interpolate_edits(
old_snapshot: &TextBufferSnapshot,
new_snapshot: &TextBufferSnapshot,
current_edits: Arc<[(Range<Anchor>, Arc<str>)]>,
) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
let mut edits = Vec::new();
let mut model_edits = current_edits.iter().peekable();
for user_edit in new_snapshot.edits_since::<usize>(&old_snapshot.version) {
while let Some((model_old_range, _)) = model_edits.peek() {
let model_old_range = model_old_range.to_offset(old_snapshot);
if model_old_range.end < user_edit.old.start {
let (model_old_range, model_new_text) = model_edits.next().unwrap();
edits.push((model_old_range.clone(), model_new_text.clone()));
} else {
break;
}
}
if let Some((model_old_range, model_new_text)) = model_edits.peek() {
let model_old_offset_range = model_old_range.to_offset(old_snapshot);
if user_edit.old == model_old_offset_range {
let user_new_text = new_snapshot
.text_for_range(user_edit.new.clone())
.collect::<String>();
if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) {
if !model_suffix.is_empty() {
let anchor = old_snapshot.anchor_after(user_edit.old.end);
edits.push((anchor..anchor, model_suffix.into()));
}
model_edits.next();
continue;
}
}
}
return None;
}
edits.extend(model_edits.cloned());
if edits.is_empty() { None } else { Some(edits) }
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{App, Entity, TestAppContext, prelude::*};
use language::{Buffer, ToOffset as _};
#[gpui::test]
async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) {
let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx));
let edits: Arc<[(Range<Anchor>, Arc<str>)]> = cx.update(|cx| {
to_prediction_edits([(2..5, "REM".into()), (9..11, "".into())], &buffer, cx).into()
});
let edit_preview = cx
.read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx))
.await;
let prediction = EditPrediction {
id: EditPredictionId("prediction-1".into()),
edits,
snapshot: cx.read(|cx| buffer.read(cx).snapshot()),
buffer: buffer.clone(),
edit_preview,
inputs: EditPredictionInputs {
events: vec![],
included_files: vec![],
cursor_point: cloud_llm_client::predict_edits_v3::Point {
line: cloud_llm_client::predict_edits_v3::Line(0),
column: 0,
},
cursor_path: Path::new("path.txt").into(),
},
buffer_snapshotted_at: Instant::now(),
response_received_at: Instant::now(),
};
cx.update(|cx| {
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(2..5, "REM".into()), (9..11, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(2..2, "REM".into()), (6..8, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.undo(cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(2..5, "REM".into()), (9..11, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(3..3, "EM".into()), (7..9, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(4..4, "M".into()), (8..10, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(9..11, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(4..4, "M".into()), (8..10, "".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx));
assert_eq!(
from_prediction_edits(
&prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(),
&buffer,
cx
),
vec![(4..4, "M".into())]
);
buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx));
assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None);
})
}
fn to_prediction_edits(
iterator: impl IntoIterator<Item = (Range<usize>, Arc<str>)>,
buffer: &Entity<Buffer>,
cx: &App,
) -> Vec<(Range<Anchor>, Arc<str>)> {
let buffer = buffer.read(cx);
iterator
.into_iter()
.map(|(range, text)| {
(
buffer.anchor_after(range.start)..buffer.anchor_before(range.end),
text,
)
})
.collect()
}
fn from_prediction_edits(
editor_edits: &[(Range<Anchor>, Arc<str>)],
buffer: &Entity<Buffer>,
cx: &App,
) -> Vec<(Range<usize>, Arc<str>)> {
let buffer = buffer.read(cx);
editor_edits
.iter()
.map(|(range, text)| {
(
range.start.to_offset(buffer)..range.end.to_offset(buffer),
text.clone(),
)
})
.collect()
}
}
+440
View File
@@ -0,0 +1,440 @@
use anyhow::{Context as _, Result};
use cloud_llm_client::predict_edits_v3::Event;
use credentials_provider::CredentialsProvider;
use edit_prediction_context::RelatedFile;
use futures::{AsyncReadExt as _, FutureExt, future::Shared};
use gpui::{
App, AppContext as _, Entity, Task,
http_client::{self, AsyncBody, Method},
};
use language::{Buffer, BufferSnapshot, Point, ToOffset as _, ToPoint as _};
use lsp::DiagnosticSeverity;
use project::{Project, ProjectPath};
use serde::{Deserialize, Serialize};
use std::{
collections::VecDeque,
fmt::{self, Write as _},
ops::Range,
path::Path,
sync::Arc,
time::Instant,
};
use crate::{EditPredictionId, EditPredictionInputs, prediction::EditPredictionResult};
const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete";
pub struct SweepAi {
pub api_token: Shared<Task<Option<String>>>,
pub debug_info: Arc<str>,
}
impl SweepAi {
pub fn new(cx: &App) -> Self {
SweepAi {
api_token: load_api_token(cx).shared(),
debug_info: debug_info(cx),
}
}
pub fn set_api_token(&mut self, api_token: Option<String>, cx: &mut App) -> Task<Result<()>> {
self.api_token = Task::ready(api_token.clone()).shared();
store_api_token_in_keychain(api_token, cx)
}
pub fn request_prediction_with_sweep(
&self,
project: &Entity<Project>,
active_buffer: &Entity<Buffer>,
snapshot: BufferSnapshot,
position: language::Anchor,
events: Vec<Arc<Event>>,
recent_paths: &VecDeque<ProjectPath>,
related_files: Vec<RelatedFile>,
diagnostic_search_range: Range<Point>,
cx: &mut App,
) -> Task<Result<Option<EditPredictionResult>>> {
let debug_info = self.debug_info.clone();
let Some(api_token) = self.api_token.clone().now_or_never().flatten() else {
return Task::ready(Ok(None));
};
let full_path: Arc<Path> = snapshot
.file()
.map(|file| file.full_path(cx))
.unwrap_or_else(|| "untitled".into())
.into();
let project_file = project::File::from_dyn(snapshot.file());
let repo_name = project_file
.map(|file| file.worktree.read(cx).root_name_str())
.unwrap_or("untitled")
.into();
let offset = position.to_offset(&snapshot);
let recent_buffers = recent_paths.iter().cloned();
let http_client = cx.http_client();
let recent_buffer_snapshots = recent_buffers
.filter_map(|project_path| {
let buffer = project.read(cx).get_open_buffer(&project_path, cx)?;
if active_buffer == &buffer {
None
} else {
Some(buffer.read(cx).snapshot())
}
})
.take(3)
.collect::<Vec<_>>();
let cursor_point = position.to_point(&snapshot);
let buffer_snapshotted_at = Instant::now();
let result = cx.background_spawn(async move {
let text = snapshot.text();
let mut recent_changes = String::new();
for event in &events {
write_event(event.as_ref(), &mut recent_changes).unwrap();
}
let mut file_chunks = recent_buffer_snapshots
.into_iter()
.map(|snapshot| {
let end_point = Point::new(30, 0).min(snapshot.max_point());
FileChunk {
content: snapshot.text_for_range(Point::zero()..end_point).collect(),
file_path: snapshot
.file()
.map(|f| f.path().as_unix_str())
.unwrap_or("untitled")
.to_string(),
start_line: 0,
end_line: end_point.row as usize,
timestamp: snapshot.file().and_then(|file| {
Some(
file.disk_state()
.mtime()?
.to_seconds_and_nanos_for_persistence()?
.0,
)
}),
}
})
.collect::<Vec<_>>();
let retrieval_chunks = related_files
.iter()
.flat_map(|related_file| {
related_file.excerpts.iter().map(|excerpt| FileChunk {
file_path: related_file.path.path.as_unix_str().to_string(),
start_line: excerpt.point_range.start.row as usize,
end_line: excerpt.point_range.end.row as usize,
content: excerpt.text.to_string(),
timestamp: None,
})
})
.collect();
let diagnostic_entries = snapshot.diagnostics_in_range(diagnostic_search_range, false);
let mut diagnostic_content = String::new();
let mut diagnostic_count = 0;
for entry in diagnostic_entries {
let start_point: Point = entry.range.start;
let severity = match entry.diagnostic.severity {
DiagnosticSeverity::ERROR => "error",
DiagnosticSeverity::WARNING => "warning",
DiagnosticSeverity::INFORMATION => "info",
DiagnosticSeverity::HINT => "hint",
_ => continue,
};
diagnostic_count += 1;
writeln!(
&mut diagnostic_content,
"{} at line {}: {}",
severity,
start_point.row + 1,
entry.diagnostic.message
)?;
}
if !diagnostic_content.is_empty() {
file_chunks.push(FileChunk {
file_path: format!("Diagnostics for {}", full_path.display()),
start_line: 0,
end_line: diagnostic_count,
content: diagnostic_content,
timestamp: None,
});
}
let request_body = AutocompleteRequest {
debug_info,
repo_name,
file_path: full_path.clone(),
file_contents: text.clone(),
original_file_contents: text,
cursor_position: offset,
recent_changes: recent_changes.clone(),
changes_above_cursor: true,
multiple_suggestions: false,
branch: None,
file_chunks,
retrieval_chunks,
recent_user_actions: vec![],
use_bytes: true,
// TODO
privacy_mode_enabled: false,
};
let mut buf: Vec<u8> = Vec::new();
let writer = brotli::CompressorWriter::new(&mut buf, 4096, 11, 22);
serde_json::to_writer(writer, &request_body)?;
let body: AsyncBody = buf.into();
let inputs = EditPredictionInputs {
events,
included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile {
path: full_path.clone(),
max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row),
excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt {
start_line: cloud_llm_client::predict_edits_v3::Line(0),
text: request_body.file_contents.into(),
}],
}],
cursor_point: cloud_llm_client::predict_edits_v3::Point {
column: cursor_point.column,
line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row),
},
cursor_path: full_path.clone(),
};
let request = http_client::Request::builder()
.uri(SWEEP_API_URL)
.header("Content-Type", "application/json")
.header("Authorization", format!("Bearer {}", api_token))
.header("Connection", "keep-alive")
.header("Content-Encoding", "br")
.method(Method::POST)
.body(body)?;
let mut response = http_client.send(request).await?;
let mut body: Vec<u8> = Vec::new();
response.body_mut().read_to_end(&mut body).await?;
let response_received_at = Instant::now();
if !response.status().is_success() {
anyhow::bail!(
"Request failed with status: {:?}\nBody: {}",
response.status(),
String::from_utf8_lossy(&body),
);
};
let response: AutocompleteResponse = serde_json::from_slice(&body)?;
let old_text = snapshot
.text_for_range(response.start_index..response.end_index)
.collect::<String>();
let edits = language::text_diff(&old_text, &response.completion)
.into_iter()
.map(|(range, text)| {
(
snapshot.anchor_after(response.start_index + range.start)
..snapshot.anchor_before(response.start_index + range.end),
text,
)
})
.collect::<Vec<_>>();
anyhow::Ok((
response.autocomplete_id,
edits,
snapshot,
response_received_at,
inputs,
))
});
let buffer = active_buffer.clone();
cx.spawn(async move |cx| {
let (id, edits, old_snapshot, response_received_at, inputs) = result.await?;
anyhow::Ok(Some(
EditPredictionResult::new(
EditPredictionId(id.into()),
&buffer,
&old_snapshot,
edits.into(),
buffer_snapshotted_at,
response_received_at,
inputs,
cx,
)
.await,
))
})
}
}
pub const SWEEP_CREDENTIALS_URL: &str = "https://autocomplete.sweep.dev";
pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
pub fn load_api_token(cx: &App) -> Task<Option<String>> {
if let Some(api_token) = std::env::var("SWEEP_AI_TOKEN")
.ok()
.filter(|value| !value.is_empty())
{
return Task::ready(Some(api_token));
}
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |cx| {
let (_, credentials) = credentials_provider
.read_credentials(SWEEP_CREDENTIALS_URL, &cx)
.await
.ok()??;
String::from_utf8(credentials).ok()
})
}
fn store_api_token_in_keychain(api_token: Option<String>, cx: &App) -> Task<Result<()>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |cx| {
if let Some(api_token) = api_token {
credentials_provider
.write_credentials(
SWEEP_CREDENTIALS_URL,
SWEEP_CREDENTIALS_USERNAME,
api_token.as_bytes(),
cx,
)
.await
.context("Failed to save Sweep API token to system keychain")
} else {
credentials_provider
.delete_credentials(SWEEP_CREDENTIALS_URL, cx)
.await
.context("Failed to delete Sweep API token from system keychain")
}
})
}
#[derive(Debug, Clone, Serialize)]
struct AutocompleteRequest {
pub debug_info: Arc<str>,
pub repo_name: String,
pub branch: Option<String>,
pub file_path: Arc<Path>,
pub file_contents: String,
pub recent_changes: String,
pub cursor_position: usize,
pub original_file_contents: String,
pub file_chunks: Vec<FileChunk>,
pub retrieval_chunks: Vec<FileChunk>,
pub recent_user_actions: Vec<UserAction>,
pub multiple_suggestions: bool,
pub privacy_mode_enabled: bool,
pub changes_above_cursor: bool,
pub use_bytes: bool,
}
#[derive(Debug, Clone, Serialize)]
struct FileChunk {
pub file_path: String,
pub start_line: usize,
pub end_line: usize,
pub content: String,
pub timestamp: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
struct UserAction {
pub action_type: ActionType,
pub line_number: usize,
pub offset: usize,
pub file_path: String,
pub timestamp: u64,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
enum ActionType {
CursorMovement,
InsertChar,
DeleteChar,
InsertSelection,
DeleteSelection,
}
#[derive(Debug, Clone, Deserialize)]
struct AutocompleteResponse {
pub autocomplete_id: String,
pub start_index: usize,
pub end_index: usize,
pub completion: String,
#[allow(dead_code)]
pub confidence: f64,
#[allow(dead_code)]
pub logprobs: Option<serde_json::Value>,
#[allow(dead_code)]
pub finish_reason: Option<String>,
#[allow(dead_code)]
pub elapsed_time_ms: u64,
#[allow(dead_code)]
#[serde(default, rename = "completions")]
pub additional_completions: Vec<AdditionalCompletion>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
struct AdditionalCompletion {
pub start_index: usize,
pub end_index: usize,
pub completion: String,
pub confidence: f64,
pub autocomplete_id: String,
pub logprobs: Option<serde_json::Value>,
pub finish_reason: Option<String>,
}
fn write_event(
event: &cloud_llm_client::predict_edits_v3::Event,
f: &mut impl fmt::Write,
) -> fmt::Result {
match event {
cloud_llm_client::predict_edits_v3::Event::BufferChange {
old_path,
path,
diff,
..
} => {
if old_path != path {
// TODO confirm how to do this for sweep
// writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?;
}
if !diff.is_empty() {
write!(f, "File: {}:\n{}\n", path.display(), diff)?
}
fmt::Result::Ok(())
}
}
}
fn debug_info(cx: &gpui::App) -> Arc<str> {
format!(
"Zed v{version} ({sha}) - OS: {os} - Zed v{version}",
version = release_channel::AppVersion::global(cx),
sha = release_channel::AppCommitSha::try_global(cx)
.map_or("unknown".to_string(), |sha| sha.full()),
os = client::telemetry::os_name(),
)
.into()
}
File diff suppressed because it is too large Load Diff
+637
View File
@@ -0,0 +1,637 @@
use anyhow::{Context as _, Result};
use language::{Anchor, BufferSnapshot, OffsetRangeExt as _, Point};
use std::{cmp, ops::Range, path::Path, sync::Arc};
const EDITS_TAG_NAME: &'static str = "edits";
const OLD_TEXT_TAG_NAME: &'static str = "old_text";
const NEW_TEXT_TAG_NAME: &'static str = "new_text";
const XML_TAGS: &[&str] = &[EDITS_TAG_NAME, OLD_TEXT_TAG_NAME, NEW_TEXT_TAG_NAME];
pub async fn parse_xml_edits<'a>(
input: &'a str,
get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range<Anchor>])> + Send,
) -> Result<(&'a BufferSnapshot, Vec<(Range<Anchor>, Arc<str>)>)> {
parse_xml_edits_inner(input, get_buffer)
.await
.with_context(|| format!("Failed to parse XML edits:\n{input}"))
}
async fn parse_xml_edits_inner<'a>(
input: &'a str,
get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range<Anchor>])> + Send,
) -> Result<(&'a BufferSnapshot, Vec<(Range<Anchor>, Arc<str>)>)> {
let xml_edits = extract_xml_replacements(input)?;
let (buffer, context_ranges) = get_buffer(xml_edits.file_path.as_ref())
.with_context(|| format!("no buffer for file {}", xml_edits.file_path))?;
let mut all_edits = vec![];
for (old_text, new_text) in xml_edits.replacements {
let match_range = fuzzy_match_in_ranges(old_text, buffer, context_ranges)?;
let matched_old_text = buffer
.text_for_range(match_range.clone())
.collect::<String>();
let edits_within_hunk = language::text_diff(&matched_old_text, new_text);
all_edits.extend(
edits_within_hunk
.into_iter()
.map(move |(inner_range, inner_text)| {
(
buffer.anchor_after(match_range.start + inner_range.start)
..buffer.anchor_before(match_range.start + inner_range.end),
inner_text,
)
}),
);
}
Ok((buffer, all_edits))
}
fn fuzzy_match_in_ranges(
old_text: &str,
buffer: &BufferSnapshot,
context_ranges: &[Range<Anchor>],
) -> Result<Range<usize>> {
let mut state = FuzzyMatcher::new(buffer, old_text);
let mut best_match = None;
let mut tie_match_range = None;
for range in context_ranges {
let best_match_cost = best_match.as_ref().map(|(score, _)| *score);
match (best_match_cost, state.match_range(range.to_offset(buffer))) {
(Some(lowest_cost), Some((new_cost, new_range))) => {
if new_cost == lowest_cost {
tie_match_range = Some(new_range);
} else if new_cost < lowest_cost {
tie_match_range.take();
best_match = Some((new_cost, new_range));
}
}
(None, Some(new_match)) => {
best_match = Some(new_match);
}
(None, None) | (Some(_), None) => {}
};
}
if let Some((_, best_match_range)) = best_match {
if let Some(tie_match_range) = tie_match_range {
anyhow::bail!(
"Multiple ambiguous matches:\n{:?}:\n{}\n\n{:?}:\n{}",
best_match_range.clone(),
buffer.text_for_range(best_match_range).collect::<String>(),
tie_match_range.clone(),
buffer.text_for_range(tie_match_range).collect::<String>()
);
}
return Ok(best_match_range);
}
anyhow::bail!(
"Failed to fuzzy match `old_text`:\n{}\nin:\n```\n{}\n```",
old_text,
context_ranges
.iter()
.map(|range| buffer.text_for_range(range.clone()).collect::<String>())
.collect::<Vec<String>>()
.join("```\n```")
);
}
#[derive(Debug)]
struct XmlEdits<'a> {
file_path: &'a str,
/// Vec of (old_text, new_text) pairs
replacements: Vec<(&'a str, &'a str)>,
}
fn extract_xml_replacements(input: &str) -> Result<XmlEdits<'_>> {
let mut cursor = 0;
let (edits_body_start, edits_attrs) =
find_tag_open(input, &mut cursor, EDITS_TAG_NAME)?.context("No edits tag found")?;
let file_path = edits_attrs
.trim_start()
.strip_prefix("path")
.context("no path attribute on edits tag")?
.trim_end()
.strip_prefix('=')
.context("no value for path attribute")?
.trim()
.trim_start_matches('"')
.trim_end_matches('"');
cursor = edits_body_start;
let mut edits_list = Vec::new();
while let Some((old_body_start, _)) = find_tag_open(input, &mut cursor, OLD_TEXT_TAG_NAME)? {
let old_body_end = find_tag_close(input, &mut cursor)?;
let old_text = trim_surrounding_newlines(&input[old_body_start..old_body_end]);
let (new_body_start, _) = find_tag_open(input, &mut cursor, NEW_TEXT_TAG_NAME)?
.context("no new_text tag following old_text")?;
let new_body_end = find_tag_close(input, &mut cursor)?;
let new_text = trim_surrounding_newlines(&input[new_body_start..new_body_end]);
edits_list.push((old_text, new_text));
}
Ok(XmlEdits {
file_path,
replacements: edits_list,
})
}
/// Trims a single leading and trailing newline
fn trim_surrounding_newlines(input: &str) -> &str {
let start = input.strip_prefix('\n').unwrap_or(input);
let end = start.strip_suffix('\n').unwrap_or(start);
end
}
fn find_tag_open<'a>(
input: &'a str,
cursor: &mut usize,
expected_tag: &str,
) -> Result<Option<(usize, &'a str)>> {
let mut search_pos = *cursor;
while search_pos < input.len() {
let Some(tag_start) = input[search_pos..].find("<") else {
break;
};
let tag_start = search_pos + tag_start;
if !input[tag_start + 1..].starts_with(expected_tag) {
search_pos = search_pos + tag_start + 1;
continue;
};
let after_tag_name = tag_start + expected_tag.len() + 1;
let close_bracket = input[after_tag_name..]
.find('>')
.with_context(|| format!("missing > after <{}", expected_tag))?;
let attrs_end = after_tag_name + close_bracket;
let body_start = attrs_end + 1;
let attributes = input[after_tag_name..attrs_end].trim();
*cursor = body_start;
return Ok(Some((body_start, attributes)));
}
Ok(None)
}
fn find_tag_close(input: &str, cursor: &mut usize) -> Result<usize> {
let mut depth = 1;
let mut search_pos = *cursor;
while search_pos < input.len() && depth > 0 {
let Some(bracket_offset) = input[search_pos..].find('<') else {
break;
};
let bracket_pos = search_pos + bracket_offset;
if input[bracket_pos..].starts_with("</")
&& let Some(close_end) = input[bracket_pos + 2..].find('>')
{
let close_start = bracket_pos + 2;
let tag_name = input[close_start..close_start + close_end].trim();
if XML_TAGS.contains(&tag_name) {
depth -= 1;
if depth == 0 {
*cursor = close_start + close_end + 1;
return Ok(bracket_pos);
}
}
search_pos = close_start + close_end + 1;
continue;
} else if let Some(close_bracket_offset) = input[bracket_pos..].find('>') {
let close_bracket_pos = bracket_pos + close_bracket_offset;
let tag_name = &input[bracket_pos + 1..close_bracket_pos].trim();
if XML_TAGS.contains(&tag_name) {
depth += 1;
}
}
search_pos = bracket_pos + 1;
}
anyhow::bail!("no closing tag found")
}
const REPLACEMENT_COST: u32 = 1;
const INSERTION_COST: u32 = 3;
const DELETION_COST: u32 = 10;
/// A fuzzy matcher that can process text chunks incrementally
/// and return the best match found so far at each step.
struct FuzzyMatcher<'a> {
snapshot: &'a BufferSnapshot,
query_lines: Vec<&'a str>,
matrix: SearchMatrix,
}
impl<'a> FuzzyMatcher<'a> {
fn new(snapshot: &'a BufferSnapshot, old_text: &'a str) -> Self {
let query_lines = old_text.lines().collect();
Self {
snapshot,
query_lines,
matrix: SearchMatrix::new(0),
}
}
fn match_range(&mut self, range: Range<usize>) -> Option<(u32, Range<usize>)> {
let point_range = range.to_point(&self.snapshot);
let buffer_line_count = (point_range.end.row - point_range.start.row + 1) as usize;
self.matrix
.reset(self.query_lines.len() + 1, buffer_line_count + 1);
let query_line_count = self.query_lines.len();
for row in 0..query_line_count {
let query_line = self.query_lines[row].trim();
let leading_deletion_cost = (row + 1) as u32 * DELETION_COST;
self.matrix.set(
row + 1,
0,
SearchState::new(leading_deletion_cost, SearchDirection::Up),
);
let mut buffer_lines = self.snapshot.text_for_range(range.clone()).lines();
let mut col = 0;
while let Some(buffer_line) = buffer_lines.next() {
let buffer_line = buffer_line.trim();
let up = SearchState::new(
self.matrix
.get(row, col + 1)
.cost
.saturating_add(DELETION_COST),
SearchDirection::Up,
);
let left = SearchState::new(
self.matrix
.get(row + 1, col)
.cost
.saturating_add(INSERTION_COST),
SearchDirection::Left,
);
let diagonal = SearchState::new(
if query_line == buffer_line {
self.matrix.get(row, col).cost
} else if fuzzy_eq(query_line, buffer_line) {
self.matrix.get(row, col).cost + REPLACEMENT_COST
} else {
self.matrix
.get(row, col)
.cost
.saturating_add(DELETION_COST + INSERTION_COST)
},
SearchDirection::Diagonal,
);
self.matrix
.set(row + 1, col + 1, up.min(left).min(diagonal));
col += 1;
}
}
// Find all matches with the best cost
let mut best_cost = u32::MAX;
let mut matches_with_best_cost = Vec::new();
for col in 1..=buffer_line_count {
let cost = self.matrix.get(query_line_count, col).cost;
if cost < best_cost {
best_cost = cost;
matches_with_best_cost.clear();
matches_with_best_cost.push(col as u32);
} else if cost == best_cost {
matches_with_best_cost.push(col as u32);
}
}
// Find ranges for the matches
for &match_end_col in &matches_with_best_cost {
let mut matched_lines = 0;
let mut query_row = query_line_count;
let mut match_start_col = match_end_col;
while query_row > 0 && match_start_col > 0 {
let current = self.matrix.get(query_row, match_start_col as usize);
match current.direction {
SearchDirection::Diagonal => {
query_row -= 1;
match_start_col -= 1;
matched_lines += 1;
}
SearchDirection::Up => {
query_row -= 1;
}
SearchDirection::Left => {
match_start_col -= 1;
}
}
}
let buffer_row_start = match_start_col + point_range.start.row;
let buffer_row_end = match_end_col + point_range.start.row;
let matched_buffer_row_count = buffer_row_end - buffer_row_start;
let matched_ratio = matched_lines as f32
/ (matched_buffer_row_count as f32).max(query_line_count as f32);
if matched_ratio >= 0.8 {
let buffer_start_ix = self
.snapshot
.point_to_offset(Point::new(buffer_row_start, 0));
let buffer_end_ix = self.snapshot.point_to_offset(Point::new(
buffer_row_end - 1,
self.snapshot.line_len(buffer_row_end - 1),
));
return Some((best_cost, buffer_start_ix..buffer_end_ix));
}
}
None
}
}
fn fuzzy_eq(left: &str, right: &str) -> bool {
const THRESHOLD: f64 = 0.8;
let min_levenshtein = left.len().abs_diff(right.len());
let min_normalized_levenshtein =
1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64);
if min_normalized_levenshtein < THRESHOLD {
return false;
}
strsim::normalized_levenshtein(left, right) >= THRESHOLD
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum SearchDirection {
Up,
Left,
Diagonal,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct SearchState {
cost: u32,
direction: SearchDirection,
}
impl SearchState {
fn new(cost: u32, direction: SearchDirection) -> Self {
Self { cost, direction }
}
}
struct SearchMatrix {
cols: usize,
rows: usize,
data: Vec<SearchState>,
}
impl SearchMatrix {
fn new(cols: usize) -> Self {
SearchMatrix {
cols,
rows: 0,
data: Vec::new(),
}
}
fn reset(&mut self, rows: usize, cols: usize) {
self.rows = rows;
self.cols = cols;
self.data
.fill(SearchState::new(0, SearchDirection::Diagonal));
self.data.resize(
self.rows * self.cols,
SearchState::new(0, SearchDirection::Diagonal),
);
}
fn get(&self, row: usize, col: usize) -> SearchState {
debug_assert!(row < self.rows);
debug_assert!(col < self.cols);
self.data[row * self.cols + col]
}
fn set(&mut self, row: usize, col: usize, state: SearchState) {
debug_assert!(row < self.rows && col < self.cols);
self.data[row * self.cols + col] = state;
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use indoc::indoc;
use language::Point;
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use util::path;
#[test]
fn test_extract_xml_edits() {
let input = indoc! {r#"
<edits path="test.rs">
<old_text>
old content
</old_text>
<new_text>
new content
</new_text>
</edits>
"#};
let result = extract_xml_replacements(input).unwrap();
assert_eq!(result.file_path, "test.rs");
assert_eq!(result.replacements.len(), 1);
assert_eq!(result.replacements[0].0, "old content");
assert_eq!(result.replacements[0].1, "new content");
}
#[test]
fn test_extract_xml_edits_with_wrong_closing_tags() {
let input = indoc! {r#"
<edits path="test.rs">
<old_text>
old content
</new_text>
<new_text>
new content
</old_text>
</ edits >
"#};
let result = extract_xml_replacements(input).unwrap();
assert_eq!(result.file_path, "test.rs");
assert_eq!(result.replacements.len(), 1);
assert_eq!(result.replacements[0].0, "old content");
assert_eq!(result.replacements[0].1, "new content");
}
#[test]
fn test_extract_xml_edits_with_xml_like_content() {
let input = indoc! {r#"
<edits path="component.tsx">
<old_text>
<foo><bar></bar></foo>
</old_text>
<new_text>
<foo><bar><baz></baz></bar></foo>
</new_text>
</edits>
"#};
let result = extract_xml_replacements(input).unwrap();
assert_eq!(result.file_path, "component.tsx");
assert_eq!(result.replacements.len(), 1);
assert_eq!(result.replacements[0].0, "<foo><bar></bar></foo>");
assert_eq!(
result.replacements[0].1,
"<foo><bar><baz></baz></bar></foo>"
);
}
#[test]
fn test_extract_xml_edits_with_conflicting_content() {
let input = indoc! {r#"
<edits path="component.tsx">
<old_text>
<new_text></new_text>
</old_text>
<new_text>
<old_text></old_text>
</new_text>
</edits>
"#};
let result = extract_xml_replacements(input).unwrap();
assert_eq!(result.file_path, "component.tsx");
assert_eq!(result.replacements.len(), 1);
assert_eq!(result.replacements[0].0, "<new_text></new_text>");
assert_eq!(result.replacements[0].1, "<old_text></old_text>");
}
#[test]
fn test_extract_xml_edits_multiple_pairs() {
let input = indoc! {r#"
Some reasoning before edits. Lots of thinking going on here
<edits path="test.rs">
<old_text>
first old
</old_text>
<new_text>
first new
</new_text>
<old_text>
second old
</edits>
<new_text>
second new
</old_text>
</edits>
"#};
let result = extract_xml_replacements(input).unwrap();
assert_eq!(result.file_path, "test.rs");
assert_eq!(result.replacements.len(), 2);
assert_eq!(result.replacements[0].0, "first old");
assert_eq!(result.replacements[0].1, "first new");
assert_eq!(result.replacements[1].0, "second old");
assert_eq!(result.replacements[1].1, "second new");
}
#[test]
fn test_extract_xml_edits_unexpected_eof() {
let input = indoc! {r#"
<edits path="test.rs">
<old_text>
first old
</
"#};
extract_xml_replacements(input).expect_err("Unexpected end of file");
}
#[gpui::test]
async fn test_parse_xml_edits(cx: &mut TestAppContext) {
let fs = init_test(cx);
let buffer_1_text = indoc! {r#"
one two three four
five six seven eight
nine ten eleven twelve
thirteen fourteen fifteen
sixteen seventeen eighteen
"#};
fs.insert_tree(
path!("/root"),
json!({
"file1": buffer_1_text,
}),
)
.await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/root/file1"), cx)
})
.await
.unwrap();
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let edits = indoc! {r#"
<edits path="root/file1">
<old_text>
nine ten eleven twelve
</old_text>
<new_text>
nine TEN eleven twelve!
</new_text>
</edits>
"#};
let included_ranges = [(buffer_snapshot.anchor_before(Point::new(1, 0))..Anchor::MAX)];
let (buffer, edits) = parse_xml_edits(edits, |_path| {
Some((&buffer_snapshot, included_ranges.as_slice()))
})
.await
.unwrap();
let edits = edits
.into_iter()
.map(|(range, text)| (range.to_point(&buffer), text))
.collect::<Vec<_>>();
assert_eq!(
edits,
&[
(Point::new(2, 5)..Point::new(2, 8), "TEN".into()),
(Point::new(2, 22)..Point::new(2, 22), "!".into())
]
);
}
fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
FakeFs::new(cx.background_executor.clone())
}
}
@@ -0,0 +1,239 @@
use std::{cmp, sync::Arc};
use client::{Client, UserStore};
use cloud_llm_client::EditPredictionRejectReason;
use edit_prediction_types::{DataCollectionState, Direction, EditPredictionDelegate};
use gpui::{App, Entity, prelude::*};
use language::{Buffer, ToPoint as _};
use project::Project;
use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore};
pub struct ZedEditPredictionDelegate {
store: Entity<EditPredictionStore>,
project: Entity<Project>,
singleton_buffer: Option<Entity<Buffer>>,
}
impl ZedEditPredictionDelegate {
pub fn new(
project: Entity<Project>,
singleton_buffer: Option<Entity<Buffer>>,
client: &Arc<Client>,
user_store: &Entity<UserStore>,
cx: &mut Context<Self>,
) -> Self {
let store = EditPredictionStore::global(client, user_store, cx);
store.update(cx, |store, cx| {
store.register_project(&project, cx);
});
cx.observe(&store, |_this, _ep_store, cx| {
cx.notify();
})
.detach();
Self {
project: project,
store: store,
singleton_buffer,
}
}
}
impl EditPredictionDelegate for ZedEditPredictionDelegate {
fn name() -> &'static str {
"zed-predict"
}
fn display_name() -> &'static str {
"Zed's Edit Predictions"
}
fn show_predictions_in_menu() -> bool {
true
}
fn show_tab_accept_marker() -> bool {
true
}
fn data_collection_state(&self, cx: &App) -> DataCollectionState {
if let Some(buffer) = &self.singleton_buffer
&& let Some(file) = buffer.read(cx).file()
{
let is_project_open_source =
self.store
.read(cx)
.is_file_open_source(&self.project, file, cx);
if self.store.read(cx).data_collection_choice.is_enabled() {
DataCollectionState::Enabled {
is_project_open_source,
}
} else {
DataCollectionState::Disabled {
is_project_open_source,
}
}
} else {
return DataCollectionState::Disabled {
is_project_open_source: false,
};
}
}
fn toggle_data_collection(&mut self, cx: &mut App) {
self.store.update(cx, |store, cx| {
store.toggle_data_collection_choice(cx);
});
}
fn usage(&self, cx: &App) -> Option<client::EditPredictionUsage> {
self.store.read(cx).usage(cx)
}
fn is_enabled(
&self,
_buffer: &Entity<language::Buffer>,
_cursor_position: language::Anchor,
cx: &App,
) -> bool {
let store = self.store.read(cx);
if store.edit_prediction_model == EditPredictionModel::Sweep {
store.has_sweep_api_token()
} else {
true
}
}
fn is_refreshing(&self, cx: &App) -> bool {
self.store.read(cx).is_refreshing(&self.project)
}
fn refresh(
&mut self,
buffer: Entity<language::Buffer>,
cursor_position: language::Anchor,
_debounce: bool,
cx: &mut Context<Self>,
) {
let store = self.store.read(cx);
if store.user_store.read_with(cx, |user_store, _cx| {
user_store.account_too_young() || user_store.has_overdue_invoices()
}) {
return;
}
if let Some(current) = store.current_prediction_for_buffer(&buffer, &self.project, cx)
&& let BufferEditPrediction::Local { prediction } = current
&& prediction.interpolate(buffer.read(cx)).is_some()
{
return;
}
self.store.update(cx, |store, cx| {
store.refresh_context(&self.project, &buffer, cursor_position, cx);
store.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx)
});
}
fn cycle(
&mut self,
_buffer: Entity<language::Buffer>,
_cursor_position: language::Anchor,
_direction: Direction,
_cx: &mut Context<Self>,
) {
}
fn accept(&mut self, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| {
store.accept_current_prediction(&self.project, cx);
});
}
fn discard(&mut self, cx: &mut Context<Self>) {
self.store.update(cx, |store, _cx| {
store.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project);
});
}
fn did_show(&mut self, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| {
store.did_show_current_prediction(&self.project, cx);
});
}
fn suggest(
&mut self,
buffer: &Entity<language::Buffer>,
cursor_position: language::Anchor,
cx: &mut Context<Self>,
) -> Option<edit_prediction_types::EditPrediction> {
let prediction =
self.store
.read(cx)
.current_prediction_for_buffer(buffer, &self.project, cx)?;
let prediction = match prediction {
BufferEditPrediction::Local { prediction } => prediction,
BufferEditPrediction::Jump { prediction } => {
return Some(edit_prediction_types::EditPrediction::Jump {
id: Some(prediction.id.to_string().into()),
snapshot: prediction.snapshot.clone(),
target: prediction.edits.first().unwrap().0.start,
});
}
};
let buffer = buffer.read(cx);
let snapshot = buffer.snapshot();
let Some(edits) = prediction.interpolate(&snapshot) else {
self.store.update(cx, |store, _cx| {
store.reject_current_prediction(
EditPredictionRejectReason::InterpolatedEmpty,
&self.project,
);
});
return None;
};
let cursor_row = cursor_position.to_point(&snapshot).row;
let (closest_edit_ix, (closest_edit_range, _)) =
edits.iter().enumerate().min_by_key(|(_, (range, _))| {
let distance_from_start = cursor_row.abs_diff(range.start.to_point(&snapshot).row);
let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row);
cmp::min(distance_from_start, distance_from_end)
})?;
let mut edit_start_ix = closest_edit_ix;
for (range, _) in edits[..edit_start_ix].iter().rev() {
let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row
- range.end.to_point(&snapshot).row;
if distance_from_closest_edit <= 1 {
edit_start_ix -= 1;
} else {
break;
}
}
let mut edit_end_ix = closest_edit_ix + 1;
for (range, _) in &edits[edit_end_ix..] {
let distance_from_closest_edit =
range.start.to_point(buffer).row - closest_edit_range.end.to_point(&snapshot).row;
if distance_from_closest_edit <= 1 {
edit_end_ix += 1;
} else {
break;
}
}
Some(edit_prediction_types::EditPrediction::Local {
id: Some(prediction.id.to_string().into()),
edits: edits[edit_start_ix..edit_end_ix].to_vec(),
edit_preview: Some(prediction.edit_preview.clone()),
})
}
}
+504
View File
@@ -0,0 +1,504 @@
mod input_excerpt;
use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant};
use crate::{
EditPredictionId, EditPredictionStore, ZedUpdateRequiredError,
prediction::{EditPredictionInputs, EditPredictionResult},
};
use anyhow::{Context as _, Result};
use cloud_llm_client::{
PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse,
predict_edits_v3::Event,
};
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task};
use input_excerpt::excerpt_for_cursor_position;
use language::{
Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToPoint as _, text_diff,
};
use project::{Project, ProjectPath};
use release_channel::AppVersion;
use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
const CURSOR_MARKER: &str = "<|user_cursor_is_here|>";
const START_OF_FILE_MARKER: &str = "<|start_of_file|>";
const EDITABLE_REGION_START_MARKER: &str = "<|editable_region_start|>";
const EDITABLE_REGION_END_MARKER: &str = "<|editable_region_end|>";
pub(crate) const MAX_CONTEXT_TOKENS: usize = 150;
pub(crate) const MAX_REWRITE_TOKENS: usize = 350;
pub(crate) const MAX_EVENT_TOKENS: usize = 500;
pub(crate) fn request_prediction_with_zeta1(
store: &mut EditPredictionStore,
project: &Entity<Project>,
buffer: &Entity<Buffer>,
snapshot: BufferSnapshot,
position: language::Anchor,
events: Vec<Arc<Event>>,
trigger: PredictEditsRequestTrigger,
cx: &mut Context<EditPredictionStore>,
) -> Task<Result<Option<EditPredictionResult>>> {
let buffer = buffer.clone();
let buffer_snapshotted_at = Instant::now();
let client = store.client.clone();
let llm_token = store.llm_token.clone();
let app_version = AppVersion::global(cx);
let (git_info, can_collect_file) = if let Some(file) = snapshot.file() {
let can_collect_file = store.can_collect_file(project, file, cx);
let git_info = if can_collect_file {
git_info_for_file(project, &ProjectPath::from_file(file.as_ref(), cx), cx)
} else {
None
};
(git_info, can_collect_file)
} else {
(None, false)
};
let full_path: Arc<Path> = snapshot
.file()
.map(|f| Arc::from(f.full_path(cx).as_path()))
.unwrap_or_else(|| Arc::from(Path::new("untitled")));
let full_path_str = full_path.to_string_lossy().into_owned();
let cursor_point = position.to_point(&snapshot);
let prompt_for_events = {
let events = events.clone();
move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS)
};
let gather_task = gather_context(
full_path_str,
&snapshot,
cursor_point,
prompt_for_events,
trigger,
cx,
);
cx.spawn(async move |this, cx| {
let GatherContextOutput {
mut body,
context_range,
editable_range,
included_events_count,
} = gather_task.await?;
let done_gathering_context_at = Instant::now();
let included_events = &events[events.len() - included_events_count..events.len()];
body.can_collect_data = can_collect_file
&& this
.read_with(cx, |this, _| this.can_collect_events(included_events))
.unwrap_or(false);
if body.can_collect_data {
body.git_info = git_info;
}
log::debug!(
"Events:\n{}\nExcerpt:\n{:?}",
body.input_events,
body.input_excerpt
);
let http_client = client.http_client();
let response = EditPredictionStore::send_api_request::<PredictEditsResponse>(
|request| {
let uri = if let Ok(predict_edits_url) = std::env::var("ZED_PREDICT_EDITS_URL") {
predict_edits_url
} else {
http_client
.build_zed_llm_url("/predict_edits/v2", &[])?
.as_str()
.into()
};
Ok(request
.uri(uri)
.body(serde_json::to_string(&body)?.into())?)
},
client,
llm_token,
app_version,
)
.await;
let inputs = EditPredictionInputs {
events: included_events.into(),
included_files: vec![cloud_llm_client::predict_edits_v3::RelatedFile {
path: full_path.clone(),
max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row),
excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt {
start_line: cloud_llm_client::predict_edits_v3::Line(context_range.start.row),
text: snapshot
.text_for_range(context_range)
.collect::<String>()
.into(),
}],
}],
cursor_point: cloud_llm_client::predict_edits_v3::Point {
column: cursor_point.column,
line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row),
},
cursor_path: full_path,
};
// let response = perform_predict_edits(PerformPredictEditsParams {
// client,
// llm_token,
// app_version,
// body,
// })
// .await;
let (response, usage) = match response {
Ok(response) => response,
Err(err) => {
if err.is::<ZedUpdateRequiredError>() {
cx.update(|cx| {
this.update(cx, |ep_store, _cx| {
ep_store.update_required = true;
})
.ok();
let error_message: SharedString = err.to_string().into();
show_app_notification(
NotificationId::unique::<ZedUpdateRequiredError>(),
cx,
move |cx| {
cx.new(|cx| {
ErrorMessagePrompt::new(error_message.clone(), cx)
.with_link_button("Update Zed", "https://zed.dev/releases")
})
},
);
})
.ok();
}
return Err(err);
}
};
let received_response_at = Instant::now();
log::debug!("completion response: {}", &response.output_excerpt);
if let Some(usage) = usage {
this.update(cx, |this, cx| {
this.user_store.update(cx, |user_store, cx| {
user_store.update_edit_prediction_usage(usage, cx);
});
})
.ok();
}
let edit_prediction = process_completion_response(
response,
buffer,
&snapshot,
editable_range,
inputs,
buffer_snapshotted_at,
received_response_at,
cx,
)
.await;
let finished_at = Instant::now();
// record latency for ~1% of requests
if rand::random::<u8>() <= 2 {
telemetry::event!(
"Edit Prediction Request",
context_latency = done_gathering_context_at
.duration_since(buffer_snapshotted_at)
.as_millis(),
request_latency = received_response_at
.duration_since(done_gathering_context_at)
.as_millis(),
process_latency = finished_at.duration_since(received_response_at).as_millis()
);
}
edit_prediction.map(Some)
})
}
fn process_completion_response(
prediction_response: PredictEditsResponse,
buffer: Entity<Buffer>,
snapshot: &BufferSnapshot,
editable_range: Range<usize>,
inputs: EditPredictionInputs,
buffer_snapshotted_at: Instant,
received_response_at: Instant,
cx: &AsyncApp,
) -> Task<Result<EditPredictionResult>> {
let snapshot = snapshot.clone();
let request_id = prediction_response.request_id;
let output_excerpt = prediction_response.output_excerpt;
cx.spawn(async move |cx| {
let output_excerpt: Arc<str> = output_excerpt.into();
let edits: Arc<[(Range<Anchor>, Arc<str>)]> = cx
.background_spawn({
let output_excerpt = output_excerpt.clone();
let editable_range = editable_range.clone();
let snapshot = snapshot.clone();
async move { parse_edits(output_excerpt, editable_range, &snapshot) }
})
.await?
.into();
let id = EditPredictionId(request_id.into());
Ok(EditPredictionResult::new(
id,
&buffer,
&snapshot,
edits,
buffer_snapshotted_at,
received_response_at,
inputs,
cx,
)
.await)
})
}
fn parse_edits(
output_excerpt: Arc<str>,
editable_range: Range<usize>,
snapshot: &BufferSnapshot,
) -> Result<Vec<(Range<Anchor>, Arc<str>)>> {
let content = output_excerpt.replace(CURSOR_MARKER, "");
let start_markers = content
.match_indices(EDITABLE_REGION_START_MARKER)
.collect::<Vec<_>>();
anyhow::ensure!(
start_markers.len() == 1,
"expected exactly one start marker, found {}",
start_markers.len()
);
let end_markers = content
.match_indices(EDITABLE_REGION_END_MARKER)
.collect::<Vec<_>>();
anyhow::ensure!(
end_markers.len() == 1,
"expected exactly one end marker, found {}",
end_markers.len()
);
let sof_markers = content
.match_indices(START_OF_FILE_MARKER)
.collect::<Vec<_>>();
anyhow::ensure!(
sof_markers.len() <= 1,
"expected at most one start-of-file marker, found {}",
sof_markers.len()
);
let codefence_start = start_markers[0].0;
let content = &content[codefence_start..];
let newline_ix = content.find('\n').context("could not find newline")?;
let content = &content[newline_ix + 1..];
let codefence_end = content
.rfind(&format!("\n{EDITABLE_REGION_END_MARKER}"))
.context("could not find end marker")?;
let new_text = &content[..codefence_end];
let old_text = snapshot
.text_for_range(editable_range.clone())
.collect::<String>();
Ok(compute_edits(
old_text,
new_text,
editable_range.start,
snapshot,
))
}
pub fn compute_edits(
old_text: String,
new_text: &str,
offset: usize,
snapshot: &BufferSnapshot,
) -> Vec<(Range<Anchor>, Arc<str>)> {
text_diff(&old_text, new_text)
.into_iter()
.map(|(mut old_range, new_text)| {
old_range.start += offset;
old_range.end += offset;
let prefix_len = common_prefix(
snapshot.chars_for_range(old_range.clone()),
new_text.chars(),
);
old_range.start += prefix_len;
let suffix_len = common_prefix(
snapshot.reversed_chars_for_range(old_range.clone()),
new_text[prefix_len..].chars().rev(),
);
old_range.end = old_range.end.saturating_sub(suffix_len);
let new_text = new_text[prefix_len..new_text.len() - suffix_len].into();
let range = if old_range.is_empty() {
let anchor = snapshot.anchor_after(old_range.start);
anchor..anchor
} else {
snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end)
};
(range, new_text)
})
.collect()
}
fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(a: T1, b: T2) -> usize {
a.zip(b)
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum()
}
fn git_info_for_file(
project: &Entity<Project>,
project_path: &ProjectPath,
cx: &App,
) -> Option<PredictEditsGitInfo> {
let git_store = project.read(cx).git_store().read(cx);
if let Some((repository, _repo_path)) =
git_store.repository_and_path_for_project_path(project_path, cx)
{
let repository = repository.read(cx);
let head_sha = repository
.head_commit
.as_ref()
.map(|head_commit| head_commit.sha.to_string());
let remote_origin_url = repository.remote_origin_url.clone();
let remote_upstream_url = repository.remote_upstream_url.clone();
if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() {
return None;
}
Some(PredictEditsGitInfo {
head_sha,
remote_origin_url,
remote_upstream_url,
})
} else {
None
}
}
pub struct GatherContextOutput {
pub body: PredictEditsBody,
pub context_range: Range<Point>,
pub editable_range: Range<usize>,
pub included_events_count: usize,
}
pub fn gather_context(
full_path_str: String,
snapshot: &BufferSnapshot,
cursor_point: language::Point,
prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static,
trigger: PredictEditsRequestTrigger,
cx: &App,
) -> Task<Result<GatherContextOutput>> {
cx.background_spawn({
let snapshot = snapshot.clone();
async move {
let input_excerpt = excerpt_for_cursor_position(
cursor_point,
&full_path_str,
&snapshot,
MAX_REWRITE_TOKENS,
MAX_CONTEXT_TOKENS,
);
let (input_events, included_events_count) = prompt_for_events();
let editable_range = input_excerpt.editable_range.to_offset(&snapshot);
let body = PredictEditsBody {
input_events,
input_excerpt: input_excerpt.prompt,
can_collect_data: false,
diagnostic_groups: None,
git_info: None,
outline: None,
speculated_output: None,
trigger,
};
Ok(GatherContextOutput {
body,
context_range: input_excerpt.context_range,
editable_range,
included_events_count,
})
}
})
}
fn prompt_for_events_impl(events: &[Arc<Event>], mut remaining_tokens: usize) -> (String, usize) {
let mut result = String::new();
for (ix, event) in events.iter().rev().enumerate() {
let event_string = format_event(event.as_ref());
let event_tokens = guess_token_count(event_string.len());
if event_tokens > remaining_tokens {
return (result, ix);
}
if !result.is_empty() {
result.insert_str(0, "\n\n");
}
result.insert_str(0, &event_string);
remaining_tokens -= event_tokens;
}
return (result, events.len());
}
pub fn format_event(event: &Event) -> String {
match event {
Event::BufferChange {
path,
old_path,
diff,
..
} => {
let mut prompt = String::new();
if old_path != path {
writeln!(
prompt,
"User renamed {} to {}\n",
old_path.display(),
path.display()
)
.unwrap();
}
if !diff.is_empty() {
write!(
prompt,
"User edited {}:\n```diff\n{}\n```",
path.display(),
diff
)
.unwrap();
}
prompt
}
}
}
/// Typical number of string bytes per token for the purposes of limiting model input. This is
/// intentionally low to err on the side of underestimating limits.
pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3;
fn guess_token_count(bytes: usize) -> usize {
bytes / BYTES_PER_TOKEN_GUESS
}
@@ -0,0 +1,231 @@
use super::{
CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER, START_OF_FILE_MARKER,
guess_token_count,
};
use language::{BufferSnapshot, Point};
use std::{fmt::Write, ops::Range};
#[derive(Debug)]
pub struct InputExcerpt {
pub context_range: Range<Point>,
pub editable_range: Range<Point>,
pub prompt: String,
}
pub fn excerpt_for_cursor_position(
position: Point,
path: &str,
snapshot: &BufferSnapshot,
editable_region_token_limit: usize,
context_token_limit: usize,
) -> InputExcerpt {
let mut scope_range = position..position;
let mut remaining_edit_tokens = editable_region_token_limit;
while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) {
let parent_tokens = guess_token_count(parent.byte_range().len());
let parent_point_range = Point::new(
parent.start_position().row as u32,
parent.start_position().column as u32,
)
..Point::new(
parent.end_position().row as u32,
parent.end_position().column as u32,
);
if parent_point_range == scope_range {
break;
} else if parent_tokens <= editable_region_token_limit {
scope_range = parent_point_range;
remaining_edit_tokens = editable_region_token_limit - parent_tokens;
} else {
break;
}
}
let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens);
let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit);
let mut prompt = String::new();
writeln!(&mut prompt, "```{path}").unwrap();
if context_range.start == Point::zero() {
writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap();
}
for chunk in snapshot.chunks(context_range.start..editable_range.start, false) {
prompt.push_str(chunk.text);
}
push_editable_range(position, snapshot, editable_range.clone(), &mut prompt);
for chunk in snapshot.chunks(editable_range.end..context_range.end, false) {
prompt.push_str(chunk.text);
}
write!(prompt, "\n```").unwrap();
InputExcerpt {
context_range,
editable_range,
prompt,
}
}
fn push_editable_range(
cursor_position: Point,
snapshot: &BufferSnapshot,
editable_range: Range<Point>,
prompt: &mut String,
) {
writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap();
for chunk in snapshot.chunks(editable_range.start..cursor_position, false) {
prompt.push_str(chunk.text);
}
prompt.push_str(CURSOR_MARKER);
for chunk in snapshot.chunks(cursor_position..editable_range.end, false) {
prompt.push_str(chunk.text);
}
write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap();
}
fn expand_range(
snapshot: &BufferSnapshot,
range: Range<Point>,
mut remaining_tokens: usize,
) -> Range<Point> {
let mut expanded_range = range;
expanded_range.start.column = 0;
expanded_range.end.column = snapshot.line_len(expanded_range.end.row);
loop {
let mut expanded = false;
if remaining_tokens > 0 && expanded_range.start.row > 0 {
expanded_range.start.row -= 1;
let line_tokens =
guess_token_count(snapshot.line_len(expanded_range.start.row) as usize);
remaining_tokens = remaining_tokens.saturating_sub(line_tokens);
expanded = true;
}
if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row {
expanded_range.end.row += 1;
expanded_range.end.column = snapshot.line_len(expanded_range.end.row);
let line_tokens = guess_token_count(expanded_range.end.column as usize);
remaining_tokens = remaining_tokens.saturating_sub(line_tokens);
expanded = true;
}
if !expanded {
break;
}
}
expanded_range
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{App, AppContext};
use indoc::indoc;
use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
use std::sync::Arc;
#[gpui::test]
fn test_excerpt_for_cursor_position(cx: &mut App) {
let text = indoc! {r#"
fn foo() {
let x = 42;
println!("Hello, world!");
}
fn bar() {
let x = 42;
let mut sum = 0;
for i in 0..x {
sum += i;
}
println!("Sum: {}", sum);
return sum;
}
fn generate_random_numbers() -> Vec<i32> {
let mut rng = rand::thread_rng();
let mut numbers = Vec::new();
for _ in 0..5 {
numbers.push(rng.random_range(1..101));
}
numbers
}
"#};
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx));
let snapshot = buffer.read(cx).snapshot();
// Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion
// when a larger scope doesn't fit the editable region.
let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32);
assert_eq!(
excerpt.prompt,
indoc! {r#"
```main.rs
let x = 42;
println!("Hello, world!");
<|editable_region_start|>
}
fn bar() {
let x = 42;
let mut sum = 0;
for i in 0..x {
sum += i;
}
println!("Sum: {}", sum);
r<|user_cursor_is_here|>eturn sum;
}
fn generate_random_numbers() -> Vec<i32> {
<|editable_region_end|>
let mut rng = rand::thread_rng();
let mut numbers = Vec::new();
```"#}
);
// The `bar` function won't fit within the editable region, so we resort to line-based expansion.
let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32);
assert_eq!(
excerpt.prompt,
indoc! {r#"
```main.rs
fn bar() {
let x = 42;
let mut sum = 0;
<|editable_region_start|>
for i in 0..x {
sum += i;
}
println!("Sum: {}", sum);
r<|user_cursor_is_here|>eturn sum;
}
fn generate_random_numbers() -> Vec<i32> {
let mut rng = rand::thread_rng();
<|editable_region_end|>
let mut numbers = Vec::new();
for _ in 0..5 {
numbers.push(rng.random_range(1..101));
```"#}
);
}
fn rust_lang() -> Language {
Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
}
}
+358
View File
@@ -0,0 +1,358 @@
#[cfg(feature = "eval-support")]
use crate::EvalCacheEntryKind;
use crate::prediction::EditPredictionResult;
use crate::{
DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionId, EditPredictionInputs,
EditPredictionRequestedDebugEvent, EditPredictionStore,
};
use anyhow::{Result, anyhow, bail};
use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat};
use cloud_llm_client::{EditPredictionRejectReason, PredictEditsRequestTrigger};
use cloud_zeta2_prompt::CURSOR_MARKER;
use edit_prediction_context::{EditPredictionExcerpt, Line};
use edit_prediction_context::{RelatedExcerpt, RelatedFile};
use futures::channel::oneshot;
use gpui::{Entity, Task, prelude::*};
use language::{Anchor, BufferSnapshot};
use language::{Buffer, Point, ToOffset as _, ToPoint};
use project::{Project, ProjectItem as _};
use release_channel::AppVersion;
use std::{
env,
path::Path,
sync::Arc,
time::{Duration, Instant},
};
pub fn request_prediction_with_zeta2(
store: &mut EditPredictionStore,
project: &Entity<Project>,
active_buffer: &Entity<Buffer>,
active_snapshot: BufferSnapshot,
position: Anchor,
events: Vec<Arc<Event>>,
mut included_files: Vec<RelatedFile>,
trigger: PredictEditsRequestTrigger,
cx: &mut Context<EditPredictionStore>,
) -> Task<Result<Option<EditPredictionResult>>> {
let options = store.options.clone();
let buffer_snapshotted_at = Instant::now();
let Some((excerpt_path, active_project_path)) = active_snapshot
.file()
.map(|file| -> Arc<Path> { file.full_path(cx).into() })
.zip(active_buffer.read(cx).project_path(cx))
else {
return Task::ready(Err(anyhow!("No file path for excerpt")));
};
let client = store.client.clone();
let llm_token = store.llm_token.clone();
let app_version = AppVersion::global(cx);
let debug_tx = store.debug_tx.clone();
let file = active_buffer.read(cx).file();
let active_file_full_path = file.as_ref().map(|f| f.full_path(cx));
// TODO data collection
let can_collect_data = file
.as_ref()
.map_or(false, |file| store.can_collect_file(project, file, cx));
#[cfg(feature = "eval-support")]
let eval_cache = store.eval_cache.clone();
let request_task = cx.background_spawn({
let active_buffer = active_buffer.clone();
async move {
let cursor_offset = position.to_offset(&active_snapshot);
let cursor_point = cursor_offset.to_point(&active_snapshot);
let before_retrieval = Instant::now();
let excerpt_options = options.context;
let Some(excerpt) = EditPredictionExcerpt::select_from_buffer(
cursor_point,
&active_snapshot,
&excerpt_options,
) else {
return Ok((None, None));
};
let excerpt_anchor_range = active_snapshot.anchor_after(excerpt.range.start)
..active_snapshot.anchor_before(excerpt.range.end);
let related_excerpt = RelatedExcerpt {
anchor_range: excerpt_anchor_range.clone(),
point_range: Point::new(excerpt.line_range.start.0, 0)
..Point::new(excerpt.line_range.end.0, 0),
text: active_snapshot.as_rope().slice(excerpt.range),
};
if let Some(buffer_ix) = included_files
.iter()
.position(|file| file.buffer.entity_id() == active_buffer.entity_id())
{
let file = &mut included_files[buffer_ix];
file.excerpts.push(related_excerpt);
file.merge_excerpts();
let last_ix = included_files.len() - 1;
included_files.swap(buffer_ix, last_ix);
} else {
let active_file = RelatedFile {
path: active_project_path,
buffer: active_buffer.downgrade(),
excerpts: vec![related_excerpt],
max_row: active_snapshot.max_point().row,
};
included_files.push(active_file);
}
let included_files = included_files
.iter()
.map(|related_file| predict_edits_v3::RelatedFile {
path: Arc::from(related_file.path.path.as_std_path()),
max_row: Line(related_file.max_row),
excerpts: related_file
.excerpts
.iter()
.map(|excerpt| predict_edits_v3::Excerpt {
start_line: Line(excerpt.point_range.start.row),
text: excerpt.text.to_string().into(),
})
.collect(),
})
.collect::<Vec<_>>();
let cloud_request = predict_edits_v3::PredictEditsRequest {
excerpt_path,
excerpt: String::new(),
excerpt_line_range: Line(0)..Line(0),
excerpt_range: 0..0,
cursor_point: predict_edits_v3::Point {
line: predict_edits_v3::Line(cursor_point.row),
column: cursor_point.column,
},
related_files: included_files,
events,
can_collect_data,
debug_info: debug_tx.is_some(),
prompt_max_bytes: Some(options.max_prompt_bytes),
prompt_format: options.prompt_format,
excerpt_parent: None,
git_info: None,
trigger,
};
let prompt_result = cloud_zeta2_prompt::build_prompt(&cloud_request);
let inputs = EditPredictionInputs {
included_files: cloud_request.related_files,
events: cloud_request.events,
cursor_point: cloud_request.cursor_point,
cursor_path: cloud_request.excerpt_path,
};
let retrieval_time = Instant::now() - before_retrieval;
let debug_response_tx = if let Some(debug_tx) = &debug_tx {
let (response_tx, response_rx) = oneshot::channel();
debug_tx
.unbounded_send(DebugEvent::EditPredictionRequested(
EditPredictionRequestedDebugEvent {
inputs: inputs.clone(),
retrieval_time,
buffer: active_buffer.downgrade(),
local_prompt: match prompt_result.as_ref() {
Ok(prompt) => Ok(prompt.clone()),
Err(err) => Err(err.to_string()),
},
position,
response_rx,
},
))
.ok();
Some(response_tx)
} else {
None
};
if cfg!(debug_assertions) && env::var("ZED_ZETA2_SKIP_REQUEST").is_ok() {
if let Some(debug_response_tx) = debug_response_tx {
debug_response_tx
.send((Err("Request skipped".to_string()), Duration::ZERO))
.ok();
}
anyhow::bail!("Skipping request because ZED_ZETA2_SKIP_REQUEST is set")
}
let prompt = prompt_result?;
let generation_params =
cloud_zeta2_prompt::generation_params(cloud_request.prompt_format);
let request = open_ai::Request {
model: EDIT_PREDICTIONS_MODEL_ID.clone(),
messages: vec![open_ai::RequestMessage::User {
content: open_ai::MessageContent::Plain(prompt),
}],
stream: false,
max_completion_tokens: None,
stop: generation_params.stop.unwrap_or_default(),
temperature: generation_params.temperature.unwrap_or(0.7),
tool_choice: None,
parallel_tool_calls: None,
tools: vec![],
prompt_cache_key: None,
reasoning_effort: None,
};
log::trace!("Sending edit prediction request");
let before_request = Instant::now();
let response = EditPredictionStore::send_raw_llm_request(
request,
client,
llm_token,
app_version,
#[cfg(feature = "eval-support")]
eval_cache,
#[cfg(feature = "eval-support")]
EvalCacheEntryKind::Prediction,
)
.await;
let received_response_at = Instant::now();
let request_time = received_response_at - before_request;
log::trace!("Got edit prediction response");
if let Some(debug_response_tx) = debug_response_tx {
debug_response_tx
.send((
response
.as_ref()
.map_err(|err| err.to_string())
.map(|response| response.0.clone()),
request_time,
))
.ok();
}
let (res, usage) = response?;
let request_id = EditPredictionId(res.id.clone().into());
let Some(mut output_text) = text_from_response(res) else {
return Ok((Some((request_id, None)), usage));
};
if output_text.contains(CURSOR_MARKER) {
log::trace!("Stripping out {CURSOR_MARKER} from response");
output_text = output_text.replace(CURSOR_MARKER, "");
}
let get_buffer_from_context = |path: &Path| {
if Some(path) == active_file_full_path.as_deref() {
Some((
&active_snapshot,
std::slice::from_ref(&excerpt_anchor_range),
))
} else {
None
}
};
let (_, edits) = match options.prompt_format {
PromptFormat::Minimal | PromptFormat::MinimalQwen | PromptFormat::SeedCoder1120 => {
if output_text.contains("--- a/\n+++ b/\nNo edits") {
let edits = vec![];
(&active_snapshot, edits)
} else {
crate::udiff::parse_diff(&output_text, get_buffer_from_context).await?
}
}
PromptFormat::OldTextNewText => {
crate::xml_edits::parse_xml_edits(&output_text, get_buffer_from_context).await?
}
_ => {
bail!("unsupported prompt format {}", options.prompt_format)
}
};
anyhow::Ok((
Some((
request_id,
Some((
inputs,
active_buffer,
active_snapshot.clone(),
edits,
received_response_at,
)),
)),
usage,
))
}
});
cx.spawn(async move |this, cx| {
let Some((id, prediction)) =
EditPredictionStore::handle_api_response(&this, request_task.await, cx)?
else {
return Ok(None);
};
let Some((inputs, edited_buffer, edited_buffer_snapshot, edits, received_response_at)) =
prediction
else {
return Ok(Some(EditPredictionResult {
id,
prediction: Err(EditPredictionRejectReason::Empty),
}));
};
Ok(Some(
EditPredictionResult::new(
id,
&edited_buffer,
&edited_buffer_snapshot,
edits.into(),
buffer_snapshotted_at,
received_response_at,
inputs,
cx,
)
.await,
))
})
}
pub fn text_from_response(mut res: open_ai::Response) -> Option<String> {
let choice = res.choices.pop()?;
let output_text = match choice.message {
open_ai::RequestMessage::Assistant {
content: Some(open_ai::MessageContent::Plain(content)),
..
} => content,
open_ai::RequestMessage::Assistant {
content: Some(open_ai::MessageContent::Multipart(mut content)),
..
} => {
if content.is_empty() {
log::error!("No output from Baseten completion response");
return None;
}
match content.remove(0) {
open_ai::MessagePart::Text { text } => text,
open_ai::MessagePart::Image { .. } => {
log::error!("Expected text, got an image");
return None;
}
}
}
_ => {
log::error!("Invalid response message: {:?}", choice.message);
return None;
}
};
Some(output_text)
}