agent-file-tools 0.45.1

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use serde_json::json;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

use aft::language::LanguageProvider;
use aft::parser::{detect_language, LangId, TreeSitterProvider};
use aft::search_index::SearchIndex;

use super::helpers::AftProcess;

fn setup_project(files: &[(&str, &str)]) -> TempDir {
    let temp_dir = tempfile::tempdir().expect("create temp dir");

    for (relative_path, content) in files {
        let path = temp_dir.path().join(relative_path);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create parent directories");
        }
        fs::write(path, content).expect("write fixture file");
    }

    temp_dir
}

fn configure(aft: &mut AftProcess, root: &Path) {
    let resp = aft.configure(root);
    assert_eq!(resp["success"], true, "configure should succeed: {resp:?}");
}

fn send(aft: &mut AftProcess, request: serde_json::Value) -> serde_json::Value {
    aft.send(&serde_json::to_string(&request).expect("serialize request"))
}

const GREETER_M: &str = r#"
@interface CKGreeter : NSObject
@property (nonatomic, copy) NSString *title;
+ (instancetype)sharedGreeter;
- (NSString *)greetWithName:(NSString *)name punctuation:(NSString *)punctuation;
@end

@protocol CKRunnable
@property (nonatomic, assign) BOOL enabled;
- (void)run;
@end

typedef NSInteger CKCount;

static NSString *CKFormatName(NSString *name) {
  return name;
}

void CKSendMessage(id obj, id bar) {
  [obj foo:bar];
}

@implementation CKGreeter
+ (instancetype)sharedGreeter {
  return [CKGreeter new];
}

- (NSString *)greetWithName:(NSString *)name punctuation:(NSString *)punctuation {
  NSString *message = [self format:name punctuation:punctuation];
  return message;
}

- (NSString *)format:(NSString *)value punctuation:(NSString *)punctuation {
  return [NSString stringWithFormat:@"%@%@", value, punctuation];
}
@end
"#;

const MIXED_MM: &str = r#"
@implementation CKObjCxxThing
- (void)touchCpp {
#ifdef __cplusplus
  std::string token = "objcxxSearchNeedle";
#endif
}
@end
"#;

#[test]
fn test_objc_outline_zoom_extensions_and_search_index() {
    let project = setup_project(&[
        ("Greeter.m", GREETER_M),
        ("Mixed.mm", MIXED_MM),
        ("Header.h", "@interface HeaderOnly : NSObject\n@end\n"),
    ]);

    assert_eq!(detect_language(Path::new("Greeter.m")), Some(LangId::ObjC));
    assert_eq!(detect_language(Path::new("Mixed.mm")), Some(LangId::ObjC));
    assert_eq!(detect_language(Path::new("Header.h")), Some(LangId::C));

    let provider = TreeSitterProvider::new();
    let symbols = provider
        .list_symbols(&project.path().join("Greeter.m"))
        .expect("list Objective-C symbols");
    let symbol_names = symbols
        .iter()
        .map(|symbol| symbol.name.as_str())
        .collect::<Vec<_>>();
    for expected in ["greetWithName:punctuation:", "format:punctuation:"] {
        assert!(
            symbol_names.contains(&expected),
            "missing symbol name {expected}: {symbol_names:?}"
        );
    }

    let mut aft = AftProcess::spawn();
    configure(&mut aft, project.path());

    let outline_resp = send(
        &mut aft,
        json!({
            "id": "outline-objc",
            "command": "outline",
            "file": project.path().join("Greeter.m"),
        }),
    );
    assert_eq!(
        outline_resp["success"], true,
        "outline should succeed: {outline_resp:?}"
    );
    let text = outline_resp["text"].as_str().expect("outline text");
    for expected in [
        "Greeter.m",
        "CKGreeter",
        "CKRunnable",
        "title",
        "enabled",
        "CKCount",
        "CKFormatName",
        "sharedGreeter",
        "greetWithName",
        "format",
    ] {
        assert!(
            text.contains(expected),
            "missing {expected} in outline: {text}"
        );
    }

    let zoom_resp = send(
        &mut aft,
        json!({
            "id": "zoom-objc-method",
            "command": "zoom",
            "file": project.path().join("Greeter.m"),
            "symbol": "greetWithName:punctuation:",
        }),
    );
    assert_eq!(
        zoom_resp["success"], true,
        "zoom should succeed: {zoom_resp:?}"
    );
    assert_eq!(zoom_resp["name"], "greetWithName:punctuation:");
    assert_eq!(zoom_resp["kind"], "method");
    let content = zoom_resp["content"].as_str().expect("zoom content");
    assert!(
        content.contains("- (NSString *)greetWithName:(NSString *)name")
            && content.contains("[self format:name punctuation:punctuation]"),
        "zoom content should contain method body: {content}"
    );

    let objcxx_outline = send(
        &mut aft,
        json!({
            "id": "outline-objcxx",
            "command": "outline",
            "file": project.path().join("Mixed.mm"),
        }),
    );
    assert_eq!(
        objcxx_outline["success"], true,
        ".mm outline should succeed: {objcxx_outline:?}"
    );
    let objcxx_text = objcxx_outline["text"].as_str().expect(".mm outline text");
    assert!(
        objcxx_text.contains("CKObjCxxThing"),
        ".mm class missing: {objcxx_text}"
    );
    assert!(
        objcxx_text.contains("touchCpp"),
        ".mm method missing: {objcxx_text}"
    );

    let index = SearchIndex::build(project.path());
    let search = index.grep("objcxxSearchNeedle", true, &[], &[], project.path(), 10);
    assert_eq!(
        search.total_matches, 1,
        ".mm file should be indexed: {search:?}"
    );
    assert!(
        search.matches.iter().any(|m| m.file.ends_with("Mixed.mm")),
        "search index should return Mixed.mm: {search:?}"
    );

    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn test_objc_ast_grep_search_with_meta_variables() {
    let project = setup_project(&[("Messages.m", GREETER_M)]);

    let mut aft = AftProcess::spawn();
    configure(&mut aft, project.path());

    let search_resp = send(
        &mut aft,
        json!({
            "id": "ast-search-objc",
            "command": "ast_search",
            "pattern": "[obj foo:$ARG]",
            "lang": "objc",
        }),
    );

    assert_eq!(
        search_resp["success"], true,
        "ast_search should succeed: {search_resp:?}"
    );
    assert_eq!(
        search_resp["total_matches"], 1,
        "Objective-C message pattern should match once: {search_resp:?}"
    );
    let captured_arg = search_resp["matches"][0]["meta_variables"]["$ARG"]
        .as_str()
        .expect("captured $ARG");
    assert_eq!(captured_arg, "bar");

    let status = aft.shutdown();
    assert!(status.success());
}