Skip to content
184 changes: 184 additions & 0 deletions crates/codegraph-cli/tests/explore_unnamed_gap_note.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! The trim note never claims a gap marker named what it could not afford to
//! (#1711, #2077).
//!
//! A gap between two rendered windows names the indexed symbols it hides, but
//! only from the budget the file has spare; a name that costs more stays out
//! and the marker is a bare `... (gap) ...`. The note closing the response
//! then said the gap markers named what was elided. Here an index-only helper
//! whose name no budget affords sits between the two windows.

use std::path::{Path, PathBuf};
use std::process::Command;

fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_codegraph"))
}

struct TestDir {
path: PathBuf,
}

impl TestDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"codegraph-cli-unnamed-gap-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&path).unwrap();
Self { path }
}
}

impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}

fn run_in(cwd: &Path, args: &[&str]) -> String {
let output = Command::new(bin())
.args(args)
.current_dir(cwd)
.env("CODEGRAPH_NO_DAEMON", "1")
.output()
.expect("run codegraph binary");
assert!(
output.status.success(),
"{args:?} failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}

/// What follows the last source fence: the epilogue.
fn epilogue_of(text: &str) -> &str {
text.rfind("```").map_or(text, |i| &text[i + 3..])
}

/// `src/big.ts`: `alphaEntry` at the top, then (when `helper_x` is given) an
/// index-only helper named `helper` plus that many X's, then `betaEntry`, whose
/// body runs `beta_lines` lines. `padding` small files lift the project into
/// the large tiers.
fn project(
label: &str,
helper_x: Option<usize>,
beta_lines: usize,
padding: usize,
) -> (TestDir, PathBuf) {
let dir = TestDir::new(label);
let root = dir.path.join("app");
let src = root.join("src");
std::fs::create_dir_all(&src).unwrap();
let mut body: Vec<String> = (1..10).map(|i| format!("// header {i}")).collect();
body.push("export function alphaEntry(): number {".to_string());
body.extend((0..19).map(|i| format!(" const a{i} = {i};")));
body.push(" return betaEntry(1);".to_string());
body.push("}".to_string());
while body.len() < 99 {
body.push(format!("// filler {}", body.len() + 1));
}
if let Some(x) = helper_x {
let helper = format!("helper{}", "X".repeat(x));
body.push(format!("function {helper}(): number {{ return 1; }}"));
}
while body.len() < 199 {
body.push(format!("// filler {}", body.len() + 1));
}
body.push("export function betaEntry(v: number): number {".to_string());
body.extend((0..beta_lines).map(|i| format!(" v = v + {i}; // beta stage {i}")));
body.push(" return v;".to_string());
body.push("}".to_string());
while body.len() < 400 {
body.push(format!("// filler {}", body.len() + 1));
}
std::fs::write(src.join("big.ts"), body.join("\n")).unwrap();
if padding > 0 {
let pad = root.join("pad");
std::fs::create_dir_all(&pad).unwrap();
for i in 0..padding {
std::fs::write(
pad.join(format!("pad{i}.ts")),
format!("export const pad{i} = {i};\n"),
)
.unwrap();
}
}
run_in(&dir.path, &["init", root.to_str().unwrap()]);
(dir, root)
}

#[test]
fn small_tier_note_does_not_claim_an_unaffordable_gap_name() {
// No per-file budget affords an 8000-character name.
let (_dir, root) = project("small", Some(8000), 19, 0);
let text = run_in(&root, &["explore", "alphaEntry betaEntry"]);
// The fixture does what it is for: both windows render around a bare gap.
assert!(text.contains("export function alphaEntry"), "{text}");
assert!(text.contains("export function betaEntry"), "{text}");
assert!(text.contains("... (gap) ..."), "{text}");
assert!(!text.contains("XXXXXXXX"), "{text}");
let epilogue = epilogue_of(&text);
assert!(
epilogue.contains("Some file sections were trimmed for size"),
"{epilogue}"
);
assert!(
!epilogue.contains("Elided symbols are named inside gap markers"),
"{epilogue}"
);
assert!(epilogue.contains("only where room allowed"), "{epilogue}");
}

#[test]
fn large_tier_note_does_not_claim_an_unaffordable_gap_name() {
let (_dir, root) = project("large", Some(8000), 900, 520);
let text = run_in(&root, &["explore", "alphaEntry betaEntry"]);
assert!(text.contains("export function alphaEntry"), "{text}");
assert!(text.contains("... (gap) ..."), "{text}");
assert!(!text.contains("XXXXXXXX"), "{text}");
let epilogue = epilogue_of(&text);
// `betaEntry` is windowed, so the section is reported trimmed.
assert!(epilogue.contains("Verbatim source for"), "{epilogue}");
assert!(!epilogue.contains("name what was elided"), "{epilogue}");
assert!(epilogue.contains("name what room allowed"), "{epilogue}");
}

/// The section a windowed `betaEntry` leaves ends in a bare tail marker: what
/// it cut is never named in a gap marker, whatever the budget. Nothing indexed
/// sits between the two functions, so no inner gap had a name to withhold.
fn assert_trailing_bare_gap(text: &str) {
assert!(text.contains("export function betaEntry"), "{text}");
assert!(text.contains("... (gap) ...\n```"), "{text}");
assert!(!text.contains("(gap: "), "{text}");
}

#[test]
fn small_tier_note_does_not_claim_names_for_a_trailing_gap() {
let (_dir, root) = project("small-tail", None, 900, 0);
let text = run_in(&root, &["explore", "alphaEntry betaEntry"]);
assert_trailing_bare_gap(&text);
let epilogue = epilogue_of(&text);
assert!(
epilogue.contains("Some file sections were trimmed for size"),
"{epilogue}"
);
assert!(
!epilogue.contains("Elided symbols are named inside gap markers"),
"{epilogue}"
);
}

#[test]
fn large_tier_note_does_not_claim_names_for_a_trailing_gap() {
let (_dir, root) = project("large-tail", None, 900, 520);
let text = run_in(&root, &["explore", "alphaEntry betaEntry"]);
assert_trailing_bare_gap(&text);
let epilogue = epilogue_of(&text);
assert!(epilogue.contains("Verbatim source for"), "{epilogue}");
assert!(!epilogue.contains("name what was elided"), "{epilogue}");
}
91 changes: 91 additions & 0 deletions crates/codegraph-cli/tests/same_line_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! Same-line namesakes survive the store, not just extraction (#1349).
//!
//! Nodes are upserted by id, so two declarations sharing one id leave a single
//! row behind. The Vue extractor mints its script functions itself; this
//! indexes a real project and reads the rows back.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

use codegraph_core::IndexPaths;
use codegraph_core::types::NodeKind;
use codegraph_store::Store;

struct TestDir {
path: PathBuf,
}

impl TestDir {
fn new(label: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"codegraph-cli-same-line-identity-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&path).unwrap();
Self { path }
}
}

impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}

fn cli(args: &[&str]) {
let output = Command::new(env!("CARGO_BIN_EXE_codegraph"))
.args(args)
.env("CODEGRAPH_NO_DAEMON", "1")
.output()
.expect("run codegraph binary");
assert!(
output.status.success(),
"codegraph {args:?} failed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}

fn store(project: &Path) -> Store {
let paths = IndexPaths::resolve(project, None).expect("resolve index paths");
Store::open_for_read(&paths, Instant::now() + Duration::from_secs(30), || false)
.expect("open the index for reading")
}

#[test]
fn same_line_vue_functions_are_two_rows() {
let dir = TestDir::new("vue");
let project = dir.path.join("proj");
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("Widget.vue"),
"<template><div/></template>\n<script>\nfunction f() {} function f() {}\n</script>\n",
)
.unwrap();
let p = project.to_str().unwrap();
cli(&["init", p]);
let rows = |project: &Path| {
let mut rows = store(project)
.all_nodes()
.unwrap()
.into_iter()
.filter(|node| node.kind == NodeKind::Function && node.name == "f")
.map(|node| (node.id, node.start_line, node.start_column))
.collect::<Vec<_>>();
rows.sort();
rows
};
let indexed = rows(&project);
assert_eq!(indexed.len(), 2, "{indexed:#?}");
assert_ne!(indexed[0].0, indexed[1].0);
cli(&["sync", p]);
assert_eq!(rows(&project), indexed, "sync keeps both rows");
cli(&["index", "--force", p]);
assert_eq!(rows(&project), indexed, "index --force keeps both rows");
}
8 changes: 6 additions & 2 deletions crates/codegraph-extract/src/embedded/vue.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use codegraph_core::node_id::generate_node_id;
use codegraph_core::node_id::{NodeIdAllocator, generate_node_id, utf16_column};
use codegraph_core::types::{
Edge, EdgeKind, ExtractionResult, Language, Node, NodeKind, UnresolvedRef,
};
Expand Down Expand Up @@ -51,6 +51,8 @@ pub struct VueExtractor<'a> {
edges: Vec<Edge>,
unresolved_references: Vec<UnresolvedRef>,
errors: Vec<String>,
/// Same-line namesakes keep distinct ids across every script block (#1349).
node_ids: NodeIdAllocator,
}

impl<'a> VueExtractor<'a> {
Expand All @@ -62,6 +64,7 @@ impl<'a> VueExtractor<'a> {
edges: Vec::new(),
unresolved_references: Vec::new(),
errors: Vec::new(),
node_ids: NodeIdAllocator::default(),
}
}

Expand Down Expand Up @@ -204,11 +207,12 @@ impl<'a> VueExtractor<'a> {
let start_pos = node.start_position();
let end_pos = node.end_position();

let id = generate_node_id(
let id = self.node_ids.generate(
self.file_path,
NodeKind::Function,
&name,
start_pos.row as u32 + 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use absolute Vue lines when allocating node IDs

For a valid SFC containing both <script> and <script setup>, functions with the same name at the same block-relative row and column receive the same ID even though their actual source lines differ; the allocator treats the second declaration as a revisit, and the store upsert drops one node. Pass the line including block.line_offset, matching the Node.start_line, so IDs follow the required file-relative formula.

AGENTS.md reference: AGENTS.md:L46-L52

Useful? React with 👍 / 👎.

utf16_column(&block.content, node.start_byte()),
);
let qualified_name = format!("{}::{}", self.file_path, name);

Expand Down
Loading
Loading