Skip to content

Commit fbf0bb7

Browse files
committed
fix: linker orphans
1 parent fddf7d3 commit fbf0bb7

3 files changed

Lines changed: 146 additions & 29 deletions

File tree

kq-core/src/check.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ pub fn rebuild_trace_graph(path: &Path) -> Result<()> {
362362
let file_path = path.join(&file.path);
363363
match file.status {
364364
git2::Delta::Added | git2::Delta::Modified => {
365-
if file_path.extension().is_some_and(|e| e == "md" || e == "tsp")
365+
if file_path.extension().is_some_and(|e| e == "md")
366366
&& let Ok(node) = docs::parse_doc_node(&file_path)
367367
{
368368
crate::db::upsert_trace_node(
@@ -384,6 +384,24 @@ pub fn rebuild_trace_graph(path: &Path) -> Result<()> {
384384
for target in &node.inline_refs {
385385
crate::db::upsert_trace_link(&db, &node.id, target, "references")?;
386386
}
387+
} else if file_path.extension().is_some_and(|e| e == "tsp") {
388+
// TypeSpec changed: re-index all models (upsert + relink)
389+
let models = typespec::list_types(path)?;
390+
for model in &models {
391+
crate::db::upsert_trace_node(
392+
&db,
393+
&model.name,
394+
"typespec",
395+
&model.name,
396+
&format!("TypeSpec/{}", model.file),
397+
1,
398+
"active",
399+
Some("TypeSpec"),
400+
)?;
401+
for target in &model.doc_refs {
402+
crate::db::upsert_trace_link(&db, &model.name, target, "covers")?;
403+
}
404+
}
387405
}
388406
}
389407
git2::Delta::Deleted => {
@@ -419,6 +437,24 @@ pub fn rebuild_trace_graph(path: &Path) -> Result<()> {
419437
crate::db::upsert_trace_link(&db, &node.id, target, "references")?;
420438
}
421439
}
440+
441+
// Index TypeSpec models as terminating trace nodes, linked to their docs
442+
let models = typespec::list_types(path)?;
443+
for model in &models {
444+
crate::db::upsert_trace_node(
445+
&db,
446+
&model.name,
447+
"typespec",
448+
&model.name,
449+
&format!("TypeSpec/{}", model.file),
450+
1,
451+
"active",
452+
Some("TypeSpec"),
453+
)?;
454+
for target in &model.doc_refs {
455+
crate::db::upsert_trace_link(&db, &model.name, target, "covers")?;
456+
}
457+
}
422458
}
423459

424460
// Mark stale links

kq-core/src/db.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,11 @@ pub fn set_last_indexed_commit(conn: &Connection, commit: &str) -> Result<()> {
195195
/// Retrieve the last indexed git commit hash, if any.
196196
pub fn get_last_indexed_commit(conn: &Connection) -> Result<Option<String>> {
197197
let result =
198-
conn.query_row("SELECT last_indexed_commit FROM schema_version WHERE version = 1", [], |row| row.get(0));
198+
conn.query_row("SELECT last_indexed_commit FROM schema_version WHERE version = 1", [], |row| {
199+
row.get::<_, Option<String>>(0)
200+
});
199201
match result {
200-
Ok(commit) => Ok(Some(commit)),
202+
Ok(commit) => Ok(commit),
201203
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
202204
Err(e) => Err(e.into()),
203205
}

kq-core/src/typespec.rs

Lines changed: 105 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,72 @@ pub fn list_types(path: &Path) -> Result<Vec<TypeModel>> {
5959
if path.extension().is_some_and(|ext| ext == "tsp") {
6060
let content = fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
6161
let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
62+
models.extend(parse_models(&content, &file_name));
63+
}
64+
}
6265

63-
let doc_refs = extract_doc_refs(&content);
66+
Ok(models)
67+
}
6468

65-
for line in content.lines() {
66-
if let Some(name) = extract_model_name(line) {
67-
models.push(TypeModel { name, file: file_name.clone(), doc_refs: doc_refs.clone() });
69+
/// Parse TypeSpec models, associating each `// @doc <id>` marker with the model it belongs to:
70+
/// - a marker inside a model body belongs to that model;
71+
/// - a marker directly preceding a `model` declaration (outside any block) belongs to that model.
72+
fn parse_models(content: &str, file_name: &str) -> Vec<TypeModel> {
73+
let mut models = Vec::new();
74+
let mut depth: usize = 0;
75+
let mut opened = false;
76+
let mut current: Option<TypeModel> = None;
77+
let mut pending_docs: Vec<String> = Vec::new();
78+
let mut in_model = false;
79+
80+
for line in content.lines() {
81+
let trimmed = line.trim();
82+
83+
if let Some(doc) = extract_doc_marker(trimmed) {
84+
if in_model {
85+
if let Some(m) = current.as_mut()
86+
&& !m.doc_refs.iter().any(|r| r == &doc)
87+
{
88+
m.doc_refs.push(doc);
89+
}
90+
} else {
91+
pending_docs.push(doc);
92+
}
93+
continue;
94+
}
95+
96+
if let Some(name) = extract_model_name(trimmed) {
97+
let mut model = TypeModel { name, file: file_name.to_string(), doc_refs: Vec::new() };
98+
if !pending_docs.is_empty() {
99+
model.doc_refs = std::mem::take(&mut pending_docs);
100+
}
101+
current = Some(model);
102+
in_model = true;
103+
}
104+
105+
if in_model {
106+
let opens = trimmed.bytes().filter(|b| *b == b'{').count();
107+
let closes = trimmed.bytes().filter(|b| *b == b'}').count();
108+
if opens > 0 {
109+
opened = true;
110+
}
111+
depth += opens;
112+
depth = depth.saturating_sub(closes);
113+
if opened && depth == 0 {
114+
if let Some(m) = current.take() {
115+
models.push(m);
68116
}
117+
in_model = false;
118+
opened = false;
69119
}
70120
}
71121
}
72122

73-
Ok(models)
123+
if let Some(m) = current.take() {
124+
models.push(m);
125+
}
126+
127+
models
74128
}
75129

76130
pub fn init_main_tsp(path: &Path) -> Result<()> {
@@ -114,6 +168,17 @@ pub fn init_main_tsp(path: &Path) -> Result<()> {
114168
Ok(())
115169
}
116170

171+
fn extract_doc_marker(line: &str) -> Option<String> {
172+
let line = line.strip_prefix("//")?.trim();
173+
let after = line.strip_prefix("@doc ")?;
174+
let id: String = after.chars().take_while(|c| !c.is_whitespace()).collect();
175+
if id.is_empty() {
176+
None
177+
} else {
178+
Some(id)
179+
}
180+
}
181+
117182
fn collect_imports(ts_dir: &Path) -> Result<Vec<String>> {
118183
let mut imports = Vec::new();
119184

@@ -151,23 +216,6 @@ fn extract_model_name(line: &str) -> Option<String> {
151216
None
152217
}
153218

154-
fn extract_doc_refs(content: &str) -> Vec<String> {
155-
let mut refs = Vec::new();
156-
for line in content.lines() {
157-
let trimmed = line.trim();
158-
if let Some(after_comment) = trimmed.strip_prefix("//") {
159-
let after_comment = after_comment.trim();
160-
if let Some(after_doc) = after_comment.strip_prefix("@doc ") {
161-
let ref_str: String = after_doc.chars().take_while(|c| !c.is_whitespace()).collect();
162-
if !ref_str.is_empty() {
163-
refs.push(ref_str);
164-
}
165-
}
166-
}
167-
}
168-
refs
169-
}
170-
171219
fn extract_import(line: &str) -> Option<String> {
172220
let trimmed = line.trim();
173221
if let Some(rest) = trimmed.strip_prefix("import \"")
@@ -313,10 +361,41 @@ mod tests {
313361
}
314362

315363
#[test]
316-
fn test_extract_doc_refs() {
317-
let content = "// @doc TZ-100\nmodel X {}\n// @doc TZ-200\nmodel Y {}";
318-
let refs = extract_doc_refs(content);
319-
assert_eq!(refs, vec!["TZ-100".to_string(), "TZ-200".to_string()]);
364+
fn test_parse_models_marker_before_model() {
365+
let models = parse_models("// @doc TZ-100\nmodel X {\n id: string;\n}\n// @doc TZ-200\nmodel Y {\n id: string;\n}", "types.tsp");
366+
assert_eq!(models.len(), 2);
367+
assert_eq!(models[0].name, "X");
368+
assert_eq!(models[0].doc_refs, vec!["TZ-100".to_string()]);
369+
assert_eq!(models[1].name, "Y");
370+
assert_eq!(models[1].doc_refs, vec!["TZ-200".to_string()]);
371+
}
372+
373+
#[test]
374+
fn test_parse_models_marker_inside_body() {
375+
let models = parse_models("model A {\n // @doc TZ-001\n id: string;\n}\nmodel B {\n id: string;\n}", "types.tsp");
376+
assert_eq!(models.len(), 2);
377+
assert_eq!(models[0].name, "A");
378+
assert_eq!(models[0].doc_refs, vec!["TZ-001".to_string()]);
379+
assert_eq!(models[1].name, "B");
380+
assert!(models[1].doc_refs.is_empty());
381+
}
382+
383+
#[test]
384+
fn test_parse_models_single_line_and_no_markers() {
385+
let models = parse_models("model P {\n id: string;\n}\nmodel Q { name: string; }\n", "types.tsp");
386+
assert_eq!(models.len(), 2);
387+
assert!(models.iter().all(|m| m.doc_refs.is_empty()));
388+
}
389+
390+
#[test]
391+
fn test_parse_models_distinct_refs_per_model() {
392+
let models = parse_models(
393+
"// @doc tz-002-\nmodel Festival {\n id: string;\n}\n// @doc tz-004-\nmodel Quest {\n id: string;\n}",
394+
"models.tsp",
395+
);
396+
assert_eq!(models.len(), 2);
397+
assert_eq!(models[0].doc_refs, vec!["tz-002-".to_string()]);
398+
assert_eq!(models[1].doc_refs, vec!["tz-004-".to_string()]);
320399
}
321400

322401
#[test]

0 commit comments

Comments
 (0)