Skip to main content

gitgraph_zed/
lib.rs

1use std::collections::{HashMap, HashSet};
2
3use gitgraph_core::log_parser::{build_graph_rows, parse_git_log_records};
4use gitgraph_core::{
5    ActionCatalog, ActionContext, ActionRequest, ActionScope, CommitSearchQuery, GitLgError,
6    filter_commits,
7};
8use zed_extension_api as zed;
9
10const DEFAULT_LIMIT: usize = 100;
11const MAX_LIMIT: usize = 1_000;
12
13fn is_log_command(name: &str) -> bool {
14    matches!(name, "gitgraph-log" | "gitlg-log")
15}
16
17fn is_search_command(name: &str) -> bool {
18    matches!(name, "gitgraph-search" | "gitlg-search")
19}
20
21fn is_actions_command(name: &str) -> bool {
22    matches!(name, "gitgraph-actions" | "gitlg-actions")
23}
24
25fn is_action_command(name: &str) -> bool {
26    matches!(name, "gitgraph-action" | "gitlg-action")
27}
28
29fn is_blame_command(name: &str) -> bool {
30    matches!(name, "gitgraph-blame" | "gitlg-blame")
31}
32
33fn is_tips_command(name: &str) -> bool {
34    matches!(name, "gitgraph-tips" | "gitlg-tips")
35}
36
37struct GitGraphZedExtension;
38
39impl zed::Extension for GitGraphZedExtension {
40    fn new() -> Self {
41        Self
42    }
43
44    fn complete_slash_command_argument(
45        &self,
46        command: zed::SlashCommand,
47        _args: Vec<String>,
48    ) -> zed::Result<Vec<zed::SlashCommandArgumentCompletion>, String> {
49        if is_log_command(command.name.as_str()) || is_search_command(command.name.as_str()) {
50            let mut items = vec![
51                zed::SlashCommandArgumentCompletion {
52                    label: "limit=25".to_string(),
53                    new_text: "limit=25".to_string(),
54                    run_command: false,
55                },
56                zed::SlashCommandArgumentCompletion {
57                    label: "limit=100".to_string(),
58                    new_text: "limit=100".to_string(),
59                    run_command: false,
60                },
61                zed::SlashCommandArgumentCompletion {
62                    label: "limit=250".to_string(),
63                    new_text: "limit=250".to_string(),
64                    run_command: false,
65                },
66            ];
67            if is_search_command(command.name.as_str()) {
68                items.push(zed::SlashCommandArgumentCompletion {
69                    label: "path=src/main.rs".to_string(),
70                    new_text: "path=src/main.rs".to_string(),
71                    run_command: false,
72                });
73            }
74            return Ok(items);
75        }
76        if is_action_command(command.name.as_str()) {
77            let completions = ActionCatalog::with_defaults()
78                .templates
79                .iter()
80                .map(|t| zed::SlashCommandArgumentCompletion {
81                    label: format!("{} ({})", t.id, t.scope.as_str()),
82                    new_text: t.id.clone(),
83                    run_command: false,
84                })
85                .collect();
86            return Ok(completions);
87        }
88        if is_blame_command(command.name.as_str()) {
89            return Ok(vec![
90                zed::SlashCommandArgumentCompletion {
91                    label: "README.md 1".to_string(),
92                    new_text: "README.md 1".to_string(),
93                    run_command: false,
94                },
95                zed::SlashCommandArgumentCompletion {
96                    label: "src/main.rs 42".to_string(),
97                    new_text: "src/main.rs 42".to_string(),
98                    run_command: false,
99                },
100            ]);
101        }
102        Ok(Vec::new())
103    }
104
105    fn run_slash_command(
106        &self,
107        command: zed::SlashCommand,
108        args: Vec<String>,
109        worktree: Option<&zed::Worktree>,
110    ) -> zed::Result<zed::SlashCommandOutput, String> {
111        let worktree = worktree.ok_or_else(|| {
112            format!(
113                "{} requires a project worktree context (open a repository first)",
114                command.name
115            )
116        })?;
117        let root = worktree.root_path();
118        let command_name = command.name.as_str();
119        if is_log_command(command_name) {
120            return run_gitgraph_log(&root, args);
121        }
122        if is_search_command(command_name) {
123            return run_gitgraph_search(&root, args);
124        }
125        if is_actions_command(command_name) {
126            return run_gitgraph_actions();
127        }
128        if is_action_command(command_name) {
129            return run_gitgraph_action(&root, args);
130        }
131        if is_blame_command(command_name) {
132            return run_gitgraph_blame(&root, args);
133        }
134        if is_tips_command(command_name) {
135            return run_gitgraph_tips();
136        }
137        Err(format!("unsupported slash command: {}", command_name))
138    }
139}
140
141fn run_gitgraph_log(repo_root: &str, args: Vec<String>) -> Result<zed::SlashCommandOutput, String> {
142    let limit = parse_limit_arg(args.first().map(String::as_str))?;
143    let output = run_git_log(repo_root, limit)?;
144    let rows = build_graph_rows(
145        parse_git_log_records(&output).map_err(|e| format!("failed to parse git output: {e}"))?,
146    );
147    let text = render_rows(
148        repo_root,
149        &rows,
150        &format!("Showing {} commit(s)", rows.len()),
151    );
152    Ok(build_output(text, "GitGraph graph"))
153}
154
155fn run_gitgraph_search(
156    repo_root: &str,
157    args: Vec<String>,
158) -> Result<zed::SlashCommandOutput, String> {
159    let parsed = parse_search_args(args)?;
160    let output = run_git_log(repo_root, parsed.limit)?;
161    let rows = build_graph_rows(
162        parse_git_log_records(&output).map_err(|e| format!("failed to parse git output: {e}"))?,
163    );
164    let search = CommitSearchQuery {
165        text: parsed.query.clone(),
166        file_path: parsed.file_path.clone(),
167        ..CommitSearchQuery::default()
168    };
169    let filtered = if let Some(path) = parsed.file_path.as_deref() {
170        filter_rows_by_file_contents(repo_root, &rows, &search, path)?
171    } else {
172        filter_commits(&rows, &search).map_err(|e| format!("search failed: {e}"))?
173    };
174    let text = render_rows(
175        repo_root,
176        &filtered,
177        &format!(
178            "Matched {} commit(s) from {} scanned",
179            filtered.len(),
180            rows.len()
181        ),
182    );
183    Ok(build_output(text, "GitGraph search"))
184}
185
186fn filter_rows_by_file_contents(
187    repo_root: &str,
188    rows: &[gitgraph_core::GraphRow],
189    search: &CommitSearchQuery,
190    file_path: &str,
191) -> Result<Vec<gitgraph_core::GraphRow>, String> {
192    if rows.is_empty() || search.text.trim().is_empty() {
193        return Ok(rows.to_vec());
194    }
195
196    let normalized = file_path.replace('\\', "/");
197    let mut matched_hashes = HashSet::new();
198    for chunk in rows.chunks(200) {
199        let mut args = vec!["grep".to_string()];
200        if search.use_regex {
201            args.push("-E".to_string());
202        } else {
203            args.push("-F".to_string());
204        }
205        if !search.case_sensitive {
206            args.push("-i".to_string());
207        }
208        args.push("-n".to_string());
209        args.push("-e".to_string());
210        args.push(search.text.clone());
211        args.extend(chunk.iter().map(|row| row.hash.clone()));
212        args.push("--".to_string());
213        args.push(normalized.clone());
214
215        let out = run_git_command(repo_root, &args)?;
216        if !matches!(out.status, Some(0) | Some(1)) {
217            return Err(format!(
218                "git grep failed (exit {:?}): {}",
219                out.status,
220                String::from_utf8_lossy(&out.stderr)
221            ));
222        }
223        for line in String::from_utf8_lossy(&out.stdout).lines() {
224            if let Some((hash, _)) = line.split_once(':') {
225                matched_hashes.insert(hash.to_string());
226            }
227        }
228    }
229
230    Ok(rows
231        .iter()
232        .filter(|row| matched_hashes.contains(&row.hash))
233        .cloned()
234        .collect())
235}
236
237fn run_gitgraph_actions() -> Result<zed::SlashCommandOutput, String> {
238    let catalog = ActionCatalog::with_defaults();
239    let mut text = String::new();
240    text.push_str("# GitGraph actions\n\n");
241    for scope in ActionScope::all() {
242        let templates = catalog.templates_for_scope(*scope);
243        text.push_str(&format!("## {} ({})\n", scope.as_str(), templates.len()));
244        for t in templates {
245            text.push_str(&format!(
246                "- `{}`: {} -> `{}`\n",
247                t.id,
248                t.title,
249                t.args.join(" ")
250            ));
251        }
252        text.push('\n');
253    }
254    Ok(build_output(text, "GitGraph actions"))
255}
256
257fn run_gitgraph_action(
258    repo_root: &str,
259    args: Vec<String>,
260) -> Result<zed::SlashCommandOutput, String> {
261    let parsed = parse_action_args(args)?;
262    let catalog = ActionCatalog::with_defaults();
263    let request = ActionRequest {
264        template_id: parsed.template_id.clone(),
265        params: parsed.params.clone(),
266        enabled_options: parsed.enabled_options.clone(),
267        context: parsed.context.clone(),
268    };
269    let resolved = catalog
270        .resolve_with_lookup(request, |placeholder| {
271            lookup_dynamic_placeholder(repo_root, placeholder)
272        })
273        .map_err(|e| format!("resolve action failed: {e}"))?;
274
275    let output = if let Some(script) = &resolved.shell_script {
276        run_shell_command(repo_root, &format!("git {}", script))?
277    } else {
278        run_git_command(repo_root, &resolved.args)?
279    };
280    let status = output.status.unwrap_or(-1);
281    if status != 0 && !resolved.allow_non_zero_exit && !resolved.ignore_errors {
282        return Err(format!(
283            "git action failed (exit {status}):\n{}",
284            String::from_utf8_lossy(&output.stderr)
285        ));
286    }
287
288    let mut text = String::new();
289    text.push_str(&format!("# GitGraph action: `{}`\n\n", resolved.id));
290    text.push_str(&format!("Command: `git {}`\n", resolved.command_line));
291    text.push_str(&format!("Exit: `{}`\n\n", status));
292    if !output.stdout.is_empty() {
293        text.push_str("## stdout\n");
294        text.push_str("```text\n");
295        text.push_str(&String::from_utf8_lossy(&output.stdout));
296        text.push_str("\n```\n");
297    }
298    if !output.stderr.is_empty() {
299        text.push_str("## stderr\n");
300        text.push_str("```text\n");
301        text.push_str(&String::from_utf8_lossy(&output.stderr));
302        text.push_str("\n```\n");
303    }
304    if output.stdout.is_empty() && output.stderr.is_empty() {
305        text.push_str("(no output)\n");
306    }
307    Ok(build_output(text, "GitGraph action"))
308}
309
310fn run_gitgraph_blame(
311    repo_root: &str,
312    args: Vec<String>,
313) -> Result<zed::SlashCommandOutput, String> {
314    let (file, line) = parse_blame_args(args)?;
315    let out = run_git_command(
316        repo_root,
317        &[
318            "blame".to_string(),
319            format!("-L{line},{line}"),
320            "--porcelain".to_string(),
321            "--".to_string(),
322            file.clone(),
323        ],
324    )?;
325    if out.status != Some(0) {
326        return Err(format!(
327            "git blame failed (exit {:?}): {}",
328            out.status,
329            String::from_utf8_lossy(&out.stderr)
330        ));
331    }
332    let text = render_blame_text(
333        repo_root,
334        &file,
335        line,
336        &String::from_utf8_lossy(&out.stdout),
337    );
338    Ok(build_output(text, "GitGraph blame"))
339}
340
341fn run_gitgraph_tips() -> Result<zed::SlashCommandOutput, String> {
342    let text = [
343        "# GitGraph tips",
344        "",
345        "- `/gitgraph-log [limit]` - show recent graph summary",
346        "- `/gitgraph-search [limit=200] [path=src/file.rs] query` - search history",
347        "- `/gitgraph-actions` - list action ids",
348        "- `/gitgraph-action <id> KEY=VALUE +opt:<option-id>` - run action",
349        "- `/gitgraph-blame <path> <line>` - single-line blame",
350        "",
351        "For full-screen interactive graph use CLI TUI in terminal:",
352        "`gitgraph`",
353    ]
354    .join("\n");
355    Ok(build_output(text, "GitGraph tips"))
356}
357
358fn parse_limit_arg(arg: Option<&str>) -> Result<usize, String> {
359    match arg {
360        None => Ok(DEFAULT_LIMIT),
361        Some(raw) if raw.trim().is_empty() => Ok(DEFAULT_LIMIT),
362        Some(raw) => {
363            let raw = raw.trim();
364            let value = if let Some((_, rhs)) = raw.split_once("limit=") {
365                rhs
366            } else {
367                raw
368            };
369            let n = value
370                .parse::<usize>()
371                .map_err(|e| format!("invalid limit {:?}: {}", raw, e))?;
372            if n == 0 || n > MAX_LIMIT {
373                return Err(format!("limit must be between 1 and {}", MAX_LIMIT));
374            }
375            Ok(n)
376        }
377    }
378}
379
380fn parse_search_args(args: Vec<String>) -> Result<ParsedSearchArgs, String> {
381    if args.is_empty() {
382        return Err("usage: /gitgraph-search [limit=200] [path=src/file.rs] <query>".to_string());
383    }
384    let mut limit: Option<usize> = None;
385    let mut file_path = None;
386    let mut query_parts = Vec::new();
387    for arg in args {
388        if limit.is_none() && (arg.starts_with("limit=") || arg.chars().all(|c| c.is_ascii_digit()))
389        {
390            limit = Some(parse_limit_arg(Some(&arg))?);
391            continue;
392        }
393        if let Some(path) = arg.strip_prefix("path=") {
394            let path = path.trim();
395            if path.is_empty() {
396                return Err("path=... value must not be empty".to_string());
397            }
398            file_path = Some(path.replace('\\', "/"));
399            continue;
400        }
401        query_parts.push(arg);
402    }
403    let query = query_parts.join(" ").trim().to_string();
404    if query.is_empty() {
405        return Err("usage: /gitgraph-search [limit=200] [path=src/file.rs] <query>".to_string());
406    }
407    Ok(ParsedSearchArgs {
408        limit: limit.unwrap_or(DEFAULT_LIMIT),
409        file_path,
410        query,
411    })
412}
413
414#[derive(Debug)]
415struct ParsedSearchArgs {
416    limit: usize,
417    file_path: Option<String>,
418    query: String,
419}
420
421fn parse_blame_args(args: Vec<String>) -> Result<(String, usize), String> {
422    if args.len() < 2 {
423        return Err("usage: /gitgraph-blame <path> <line>".to_string());
424    }
425    let file = args[0].clone();
426    let line = args[1]
427        .parse::<usize>()
428        .map_err(|e| format!("invalid line {:?}: {}", args[1], e))?;
429    if line == 0 {
430        return Err("line must be >= 1".to_string());
431    }
432    Ok((file, line))
433}
434
435#[derive(Debug)]
436struct ParsedActionArgs {
437    template_id: String,
438    params: HashMap<String, String>,
439    enabled_options: HashSet<String>,
440    context: ActionContext,
441}
442
443fn parse_action_args(args: Vec<String>) -> Result<ParsedActionArgs, String> {
444    let Some((template_id, tail)) = args.split_first() else {
445        return Err(
446            "usage: /gitgraph-action <action-id> KEY=VALUE +opt:<option-id> (e.g. BRANCH_NAME=main)"
447                .to_string(),
448        );
449    };
450    let mut params = HashMap::new();
451    let mut enabled_options = HashSet::new();
452    let mut context = ActionContext::default();
453    context.default_remote_name = Some("origin".to_string());
454
455    for token in tail {
456        if let Some(opt) = token.strip_prefix("+opt:") {
457            enabled_options.insert(opt.to_string());
458            continue;
459        }
460        let (key, value) = token
461            .split_once('=')
462            .ok_or_else(|| format!("invalid token {:?}, expected KEY=VALUE or +opt:<id>", token))?;
463        params.insert(key.to_string(), value.to_string());
464        map_context_placeholder(&mut context, key, value);
465    }
466
467    Ok(ParsedActionArgs {
468        template_id: template_id.to_string(),
469        params,
470        enabled_options,
471        context,
472    })
473}
474
475fn map_context_placeholder(context: &mut ActionContext, key: &str, value: &str) {
476    match key {
477        "BRANCH_DISPLAY_NAME" => context.branch_display_name = Some(value.to_string()),
478        "BRANCH_NAME" => context.branch_name = Some(value.to_string()),
479        "LOCAL_BRANCH_NAME" => context.local_branch_name = Some(value.to_string()),
480        "BRANCH_ID" => context.branch_id = Some(value.to_string()),
481        "SOURCE_BRANCH_NAME" => context.source_branch_name = Some(value.to_string()),
482        "TARGET_BRANCH_NAME" => context.target_branch_name = Some(value.to_string()),
483        "COMMIT_HASH" => context.commit_hash = Some(value.to_string()),
484        "COMMIT_HASHES" => {
485            context.commit_hashes = value
486                .split([',', ' '])
487                .map(str::trim)
488                .filter(|v| !v.is_empty())
489                .map(ToString::to_string)
490                .collect()
491        }
492        "COMMIT_BODY" => context.commit_body = Some(value.to_string()),
493        "STASH_NAME" => context.stash_name = Some(value.to_string()),
494        "TAG_NAME" => context.tag_name = Some(value.to_string()),
495        "REMOTE_NAME" => context.remote_name = Some(value.to_string()),
496        "DEFAULT_REMOTE_NAME" => context.default_remote_name = Some(value.to_string()),
497        _ => {
498            context
499                .additional_placeholders
500                .insert(key.to_string(), value.to_string());
501        }
502    }
503}
504
505fn lookup_dynamic_placeholder(
506    repo_root: &str,
507    placeholder: &str,
508) -> gitgraph_core::Result<Option<String>> {
509    if let Some(key) = placeholder.strip_prefix("GIT_CONFIG:") {
510        let out = run_git_command(
511            repo_root,
512            &["config".to_string(), "--get".to_string(), key.to_string()],
513        )
514        .map_err(GitLgError::State)?;
515        return Ok(Some(
516            String::from_utf8_lossy(&out.stdout).trim().to_string(),
517        ));
518    }
519    if let Some(raw_exec) = placeholder.strip_prefix("GIT_EXEC:") {
520        let args = shlex::split(raw_exec).unwrap_or_else(|| {
521            raw_exec
522                .split_whitespace()
523                .map(ToString::to_string)
524                .collect()
525        });
526        let out = run_git_command(repo_root, &args).map_err(GitLgError::State)?;
527        return Ok(Some(
528            String::from_utf8_lossy(&out.stdout).trim().to_string(),
529        ));
530    }
531    Ok(None)
532}
533
534fn run_git_log(repo_root: &str, limit: usize) -> Result<String, String> {
535    let args = vec![
536        "-c".to_string(),
537        "color.ui=never".to_string(),
538        "log".to_string(),
539        "--date-order".to_string(),
540        "--topo-order".to_string(),
541        "--decorate=full".to_string(),
542        "--color=never".to_string(),
543        "--no-show-signature".to_string(),
544        "--no-notes".to_string(),
545        "--all".to_string(),
546        "-n".to_string(),
547        limit.to_string(),
548        "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%ct%x1f%D%x1f%s%x1f%b%x1e"
549            .to_string(),
550    ];
551    let out = run_git_command(repo_root, &args)?;
552    if out.status != Some(0) {
553        return Err(format!(
554            "git log failed (exit {:?}): {}",
555            out.status,
556            String::from_utf8_lossy(&out.stderr)
557        ));
558    }
559    String::from_utf8(out.stdout).map_err(|e| format!("invalid utf-8 from git: {}", e))
560}
561
562fn run_git_command(repo_root: &str, args: &[String]) -> Result<zed::process::Output, String> {
563    let mut full_args = vec!["-C".to_string(), repo_root.to_string()];
564    full_args.extend(args.to_vec());
565    let mut cmd = zed::process::Command::new("git").args(full_args);
566    cmd.output()
567}
568
569fn run_shell_command(repo_root: &str, script: &str) -> Result<zed::process::Output, String> {
570    let (os, _arch) = zed::current_platform();
571    match os {
572        zed::Os::Windows => {
573            let mut cmd = zed::process::Command::new("cmd").args([
574                "/C".to_string(),
575                format!("cd /d \"{}\" && {}", repo_root, script),
576            ]);
577            cmd.output()
578        }
579        _ => {
580            let mut cmd = zed::process::Command::new("sh").args([
581                "-lc".to_string(),
582                format!("cd \"{}\" && {}", repo_root, script),
583            ]);
584            cmd.output()
585        }
586    }
587}
588
589fn render_rows(repo_root: &str, rows: &[gitgraph_core::GraphRow], subtitle: &str) -> String {
590    let mut out = String::new();
591    out.push_str(&format!("# GitGraph log for `{}`\n\n", repo_root));
592    out.push_str(subtitle);
593    out.push_str("\n\n");
594
595    for row in rows {
596        let graph_prefix = format!("{}*", "| ".repeat(row.lane));
597        let refs = if row.refs.is_empty() {
598            String::new()
599        } else {
600            let names = row
601                .refs
602                .iter()
603                .map(|r| r.name.as_str())
604                .collect::<Vec<_>>()
605                .join(", ");
606            format!(" ({})", names)
607        };
608        out.push_str(&format!(
609            "- {} `{}` {}{} - {}\n",
610            graph_prefix, row.short_hash, row.subject, refs, row.author_name
611        ));
612    }
613
614    if rows.is_empty() {
615        out.push_str("- (no commits found for current selection)\n");
616    }
617    out
618}
619
620fn render_blame_text(repo_root: &str, file: &str, line: usize, raw: &str) -> String {
621    let mut commit_hash = "";
622    let mut author = "";
623    let mut author_mail = "";
624    let mut summary = "";
625    let mut author_time = "";
626    for (idx, l) in raw.lines().enumerate() {
627        if idx == 0 {
628            commit_hash = l.split_whitespace().next().unwrap_or_default();
629            continue;
630        }
631        if let Some(v) = l.strip_prefix("author ") {
632            author = v;
633            continue;
634        }
635        if let Some(v) = l.strip_prefix("author-mail ") {
636            author_mail = v;
637            continue;
638        }
639        if let Some(v) = l.strip_prefix("author-time ") {
640            author_time = v;
641            continue;
642        }
643        if let Some(v) = l.strip_prefix("summary ") {
644            summary = v;
645            continue;
646        }
647    }
648    [
649        format!("# GitGraph blame for `{}`", repo_root),
650        String::new(),
651        format!("file: `{}`", file),
652        format!("line: `{}`", line),
653        format!("commit: `{}`", commit_hash),
654        format!("author: `{}` {}", author, author_mail),
655        format!("author_time_unix: `{}`", author_time),
656        format!("summary: {}", summary),
657    ]
658    .join("\n")
659}
660
661fn build_output(text: String, label: &str) -> zed::SlashCommandOutput {
662    let end = text.len() as u32;
663    zed::SlashCommandOutput {
664        text,
665        sections: vec![zed::SlashCommandOutputSection {
666            range: zed::Range { start: 0, end },
667            label: label.to_string(),
668        }],
669    }
670}
671
672zed::register_extension!(GitGraphZedExtension);