From d3cc4693000302d897bb2fd7e77c62fc95cae3e5 Mon Sep 17 00:00:00 2001 From: Mike Hilgendorf Date: Mon, 16 Jun 2025 11:42:29 -0500 Subject: [PATCH] feat: add .tangram/tags directory and update `tg tag` logic - internally forbid the creation of "branch" tags - make `tg tag list foo` equivalent to `tg tag list foo/*` when `foo` is a "branch" - create a .tangram/tags directory for containing local tags - make LSP URIs point into the tags directory for more legible/discoverable resolutions --- packages/client/src/tag.rs | 14 ++++ packages/server/src/compiler.rs | 112 +++++++++++++++++++++++++++++++- packages/server/src/lib.rs | 5 ++ packages/server/src/tag/list.rs | 83 ++++++++++++++--------- packages/server/src/tag/put.rs | 60 +++++++++++++++++ 5 files changed, 241 insertions(+), 33 deletions(-) diff --git a/packages/client/src/tag.rs b/packages/client/src/tag.rs index a50ca000e..8bdc52361 100644 --- a/packages/client/src/tag.rs +++ b/packages/client/src/tag.rs @@ -80,6 +80,11 @@ impl Tag { } self.as_str() } + + pub fn ancestors(&self) -> impl Iterator + 'static { + let components = self.components.clone(); + (1..components.len()).map(move |n| tg::Tag::with_components(components[0..n].to_vec())) + } } impl AsRef for Tag { @@ -257,4 +262,13 @@ mod tests { .is_err() ); } + + #[test] + fn ancestors() { + let tag = "foo".parse::().unwrap(); + assert!(tag.ancestors().next().is_none()); + + let tag = "foo/bar".parse::().unwrap(); + assert_eq!(tag.ancestors().next(), Some("foo".parse().unwrap())); + } } diff --git a/packages/server/src/compiler.rs b/packages/server/src/compiler.rs index 75a81c9e1..e66e74b04 100644 --- a/packages/server/src/compiler.rs +++ b/packages/server/src/compiler.rs @@ -757,6 +757,81 @@ impl Compiler { return Ok(module); } + // Handle a path in the tags directory. + if let Ok(path) = path.strip_prefix(self.server.tags_path()) { + // Check if the path is empty. + if path.as_os_str().is_empty() { + return Err(tg::error!(%uri, "invalid uri")); + } + + // Look up the tag. + let mut pattern = Vec::new(); + let mut components = path.components(); + let mut output = None; + while let Some(component) = components.next() { + let component = component + .as_os_str() + .to_str() + .ok_or_else(|| tg::error!("invalid tag component"))?; + pattern.push(component.parse()?); + let pattern = tg::tag::Pattern::with_components(pattern.clone()); + output = self.server.try_get_tag(&pattern).await?; + if output.is_some() { + break; + } + } + let output = output.ok_or_else(|| tg::error!(%uri, "could not resolve tag"))?; + let item = output + .item + .right() + .ok_or_else(|| tg::error!(%uri, "expected an object"))?; + let path: PathBuf = components.collect(); + + // Infer the kind. + let kind = if path.extension().is_some_and(|extension| extension == "js") { + tg::module::Kind::Js + } else if path.extension().is_some_and(|extension| extension == "ts") { + tg::module::Kind::Ts + } else if item.is_directory() { + tg::module::Kind::Directory + } else if item.is_file() { + tg::module::Kind::File + } else if item.is_symlink() { + tg::module::Kind::Symlink + } else { + return Err(tg::error!("expected a directory, file, or symlink")); + }; + + // Materialize the symlink if it does not exist. + let link = self.server.tags_path().join(output.tag.to_string()); + if !matches!(tokio::fs::try_exists(&link).await, Ok(true)) { + // Create the parent directory. + tokio::fs::create_dir_all(link.parent().unwrap()) + .await + .map_err(|source| tg::error!(!source, "failed to create the tag directory"))?; + + // Get the symlink target. + let artifact = self.server.artifacts_path().join(item.to_string()); + let target = crate::util::path::diff(link.parent().unwrap(), &artifact)?; + + // Create the symlink. + tokio::fs::symlink(target, link) + .await + .map_err(|source| tg::error!(!source, "failed to get the symlink"))?; + } + + // Create the referent. + let path = (!path.as_os_str().is_empty()).then_some(path); + let referent = tg::Referent { + item: tg::module::data::Item::Object(item), + path, + tag: Some(output.tag), + }; + + // Return the module. + return Ok(tg::module::Data { kind, referent }); + } + // Handle a path in the cache directory. if let Ok(path) = path.strip_prefix(self.server.cache_path()) { #[allow(clippy::case_sensitive_file_extension_comparisons)] @@ -810,7 +885,6 @@ impl Compiler { // Create the module. let module = self.server.module_for_path(path).await?; - Ok(module) } @@ -857,6 +931,42 @@ impl Compiler { Ok(uri) }, + tg::module::Data { + referent: + tg::Referent { + item: tg::module::data::Item::Object(object), + tag: Some(tag), + path: subpath, + }, + .. + } => { + let artifact = object + .clone() + .try_into() + .map_err(|_| tg::error!("expected an artifact"))?; + if self.server.vfs.lock().unwrap().is_none() { + let arg = tg::checkout::Arg { + artifact, + dependencies: false, + force: false, + lockfile: false, + path: None, + }; + self.server + .checkout(arg) + .await? + .map_ok(|_| ()) + .try_collect::<()>() + .await?; + } + let path = self.server.tags_path().join(tag.to_string()); + let path = subpath + .as_ref() + .map_or_else(|| path.clone(), |p| path.join(p)); + let uri = format!("file://{}", path.display()).parse().unwrap(); + Ok(uri) + }, + tg::module::Data { referent: tg::Referent { diff --git a/packages/server/src/lib.rs b/packages/server/src/lib.rs index 35b4c7298..d8eab0a42 100644 --- a/packages/server/src/lib.rs +++ b/packages/server/src/lib.rs @@ -855,6 +855,11 @@ impl Server { self.path.join("logs") } + #[must_use] + pub fn tags_path(&self) -> PathBuf { + self.path.join("tags") + } + #[must_use] pub fn temp_path(&self) -> PathBuf { self.path.join("tmp") diff --git a/packages/server/src/tag/list.rs b/packages/server/src/tag/list.rs index 3a0d10dcf..16d858261 100644 --- a/packages/server/src/tag/list.rs +++ b/packages/server/src/tag/list.rs @@ -7,6 +7,12 @@ use tangram_database::{self as db, prelude::*}; use tangram_either::Either; use tangram_http::{Body, request::Ext as _, response::builder::Ext as _}; +#[derive(Clone, Debug, serde::Deserialize)] +struct Row { + tag: tg::Tag, + item: Either, +} + impl Server { pub async fn list_tags( &self, @@ -53,6 +59,46 @@ impl Server { } async fn list_tags_local(&self, arg: tg::tag::list::Arg) -> tg::Result { + let mut rows = self.query_tags(&arg.pattern).await?; + + // If there is no match and the last component is normal, search for any versioned items. + if matches!( + arg.pattern.components().last(), + Some(tg::tag::pattern::Component::Normal(_)) + ) && rows.is_empty() + { + let mut pattern = arg.pattern.into_components(); + pattern.push(tg::tag::pattern::Component::Wildcard); + rows = self + .query_tags(&tg::tag::Pattern::with_components(pattern)) + .await?; + } + + // Reverse if requested. + if arg.reverse { + rows.reverse(); + } + + // Limit. + if let Some(length) = arg.length { + rows.truncate(length.to_usize().unwrap()); + } + + // Create the output. + let data = rows + .into_iter() + .map(|row| tg::tag::get::Output { + tag: row.tag, + item: row.item, + remote: None, + }) + .collect(); + let output = tg::tag::list::Output { data }; + + Ok(output) + } + + async fn query_tags(&self, pattern: &tg::tag::Pattern) -> tg::Result> { // Get a database connection. let connection = self .database @@ -60,19 +106,13 @@ impl Server { .await .map_err(|source| tg::error!(!source, "failed to get a database connection"))?; - #[derive(Clone, Debug, serde::Deserialize)] - struct Row { - tag: tg::Tag, - item: Either, - } let p = connection.p(); - let prefix = arg - .pattern + let prefix = pattern .as_str() .char_indices() .find(|(_, c)| !(c.is_alphanumeric() || matches!(c, '.' | '_' | '+' | '-' | '/'))) - .map_or(arg.pattern.as_str().len(), |(i, _)| i); - let prefix = &arg.pattern.as_str()[..prefix]; + .map_or(pattern.as_str().len(), |(i, _)| i); + let prefix = &pattern.as_str()[..prefix]; let statement = formatdoc!( " select tag, item @@ -87,33 +127,12 @@ impl Server { .map_err(|source| tg::error!(!source, "failed to execute the statement"))?; // Filter the rows. - rows.retain(|row| arg.pattern.matches(&row.tag)); + rows.retain(|row| pattern.matches(&row.tag)); // Sort the rows. rows.sort_by(|a, b| a.tag.cmp(&b.tag)); - // Reverse if requested. - if arg.reverse { - rows.reverse(); - } - - // Limit. - if let Some(length) = arg.length { - rows.truncate(length.to_usize().unwrap()); - } - - // Create the output. - let data = rows - .into_iter() - .map(|row| tg::tag::get::Output { - tag: row.tag, - item: row.item, - remote: None, - }) - .collect(); - let output = tg::tag::list::Output { data }; - - Ok(output) + Ok(rows) } pub(crate) async fn handle_list_tags_request( diff --git a/packages/server/src/tag/put.rs b/packages/server/src/tag/put.rs index af51c3640..4781943d0 100644 --- a/packages/server/src/tag/put.rs +++ b/packages/server/src/tag/put.rs @@ -7,6 +7,10 @@ use tangram_messenger::prelude::*; impl Server { pub async fn put_tag(&self, tag: &tg::Tag, mut arg: tg::tag::put::Arg) -> tg::Result<()> { + if tag.is_empty() { + return Err(tg::error!("invalid tag")); + } + // If the remote arg is set, then forward the request. if let Some(remote) = arg.remote.take() { let remote = self.get_remote_client(remote).await?; @@ -17,6 +21,9 @@ impl Server { remote.put_tag(tag, arg).await?; } + // Check if there are any ancestors of this tag. + self.check_tag_ancestors(tag).await?; + // Get a database connection. let connection = self .database @@ -39,6 +46,16 @@ impl Server { .await .map_err(|source| tg::error!(!source, "failed to execute the statement"))?; + // Create the tag entry. + if let Some(artifact) = arg + .item + .as_ref() + .right() + .and_then(|id| id.clone().try_into().ok()) + { + self.create_tag_dir_entry(tag, &artifact).await?; + } + // Send the tag message. let message = crate::index::Message::PutTag(crate::index::PutTagMessage { tag: tag.to_string(), @@ -55,6 +72,49 @@ impl Server { Ok(()) } + async fn check_tag_ancestors(&self, tag: &tg::Tag) -> tg::Result<()> { + let connection = self + .database + .connection() + .await + .map_err(|source| tg::error!(!source, "failed to get a database connection"))?; + let p = connection.p(); + let statement = formatdoc!( + " + select count(*) from tags where tag = {p}1; + " + ); + for ancestor in tag.ancestors() { + let params = db::params![ancestor.to_string()]; + let count = connection + .query_one_value_into::(statement.clone().into(), params) + .await + .map_err(|source| tg::error!(!source, "failed to perform the query"))?; + if count != 0 { + return Err(tg::error!("there is an existing tag `{ancestor}`")); + } + } + Ok(()) + } + + async fn create_tag_dir_entry( + &self, + tag: &tg::Tag, + artifact: &tg::artifact::Id, + ) -> tg::Result<()> { + let link = self.tags_path().join(tag.to_string()); + tokio::fs::create_dir_all(link.parent().unwrap()) + .await + .map_err(|source| tg::error!(!source, "failed to create the tag directory"))?; + crate::util::fs::remove(&link).await.ok(); + let target = self.artifacts_path().join(artifact.to_string()); + let path = crate::util::path::diff(link.parent().unwrap(), &target)?; + tokio::fs::symlink(path, &link) + .await + .map_err(|source| tg::error!(!source, "failed to create the tag entry"))?; + Ok(()) + } + pub(crate) async fn handle_put_tag_request( handle: &H, request: http::Request,