Skip to content

Add plot file/origin attribution#1067

Merged
jmcphers merged 10 commits intomainfrom
feature/plot-file-attribution
Mar 3, 2026
Merged

Add plot file/origin attribution#1067
jmcphers merged 10 commits intomainfrom
feature/plot-file-attribution

Conversation

@jmcphers
Copy link
Contributor

Companion to posit-dev/positron#12168; adds additional metadata to plots annotating where they originated, to make it easy to navigate to that location. See that PR for more info.

For linewise execution, this borrows from the source location infrastructure @lionel- set up already for debugging; for execution via source(), some slightly more involved work is needed to store the source context so we can get at it later.

Copy link
Contributor

@DavisVaughan DavisVaughan left a comment

Choose a reason for hiding this comment

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

I'd like to get @lionel-'s opinion on a few questions about where things should live, but otherwise seems good

# is not yet available (e.g. during development with mismatched builds).
if (!is.null(source_uri)) {
pushed <- tryCatch(
{ .ps.Call("ps_graphics_push_source_context", source_uri); TRUE },
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you please reformat with Air? I should probably turn that on as a CI check...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, it looks like a lot of the .R files are not air-compliant. I went ahead and formatted the whole workspace (LMK if you'd like to keep the changes scoped to just this new file)

error = function(e) FALSE
)
if (pushed) {
on.exit(tryCatch(.ps.Call("ps_graphics_pop_source_context"), error = function(e) NULL), add = TRUE)
Copy link
Contributor

Choose a reason for hiding this comment

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

We are trying to use defer() instead of on.exit(), which comes with the right defaults

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done!


# Try to resolve the file URI early so we can attribute plots to this
# source file. This is best-effort; if it fails we proceed without attribution.
source_uri <- tryCatch(path_to_file_uri(file), error = function(e) NULL)
Copy link
Contributor

Choose a reason for hiding this comment

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

I see no reason to not just consistently call it uri everywhere rather than renaming it later on

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done!

// Push execution context to graphics device for plot attribution
// Push execution context to graphics device for plot attribution.
// Extract code_location from the execute request for source file origin.
let code_location = exec_req.code_location().log_err().flatten().map(|mut loc| {
Copy link
Contributor

Choose a reason for hiding this comment

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

@lionel- should code_location() just normalize() the URI internally? It seems like the one other place we call code_location() also normalizes after the fact.

Copy link
Contributor

Choose a reason for hiding this comment

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

That's a good idea!

code_location() lives in Amalthea and ExtUrl::normalize() is in Ark, so a nice place to implement it would be on the ExtUrl extension trait:

let code_location = ExtUrl::from_execute_request(&exec_req);

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually I realised while working on #1082 that we should not normalise/canonicalise URIs that flow back to the frontend, because it might think it's a different file. I noticed Positron would open a canonicalised path in a new editor instead of reusing an existing one.

Fixed in the linked PR.

Comment on lines +1325 to +1340
/// Push a source file URI onto the source context stack.
/// Called from the `source()` hook when entering a sourced file.
#[harp::register]
unsafe extern "C-unwind" fn ps_graphics_push_source_context(uri: SEXP) -> anyhow::Result<SEXP> {
let uri_str: String = RObject::view(uri).try_into()?;
DEVICE_CONTEXT.with_borrow(|cell| cell.push_source_context(uri_str));
Ok(harp::r_null())
}

/// Pop a source file URI from the source context stack.
/// Called from the `source()` hook when leaving a sourced file.
#[harp::register]
unsafe extern "C-unwind" fn ps_graphics_pop_source_context() -> anyhow::Result<SEXP> {
DEVICE_CONTEXT.with_borrow(|cell| cell.pop_source_context());
Ok(harp::r_null())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

IIUC the desire is to be able to query the name of the file that is currently being source()-ed at any time while inside the execution of that file.

That doesn't feel super specific to plots, so I would not be against that being a generic feature that lives in Console instead.

@lionel- what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

That makes sense to me, although I can't think of a reason to do it proactively.

I was initially thinking it may help with transitions of comms to Console thread, as the stack could be made available via the comm context. But the graphics device is not a comm so I don't think that will help there.

Feel free to add the source context to the Console though Jonathan.

In that case it might make sense to include the execute request's location in the stack so that priority resolution is consistently handled.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, agree that could be helpful but won't do it proactively here!

fn new_positron_page(&self) {
self.is_new_page.replace(true);
self.id.replace(Self::new_id());
*self.pending_origin.borrow_mut() = None;
Copy link
Contributor

Choose a reason for hiding this comment

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

Use self.pending_origin.replace(None) like we've done for the other fields?

Here and elsewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done!

Comment on lines +1087 to +1092
// Clear any unconsumed pending origin so it doesn't leak into the
// next execute request. `process_changes()` above already consumed it
// if this execution produced a new plot; this handles the case where
// drawing occurred (e.g. `lines()` inside `source()`) but only as an
// update to an existing plot, leaving the pending origin unclaimed.
*cell.pending_origin.borrow_mut() = None;
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we want set_pending_origin() and clear_pending_origin() to go along with set_execution_context() and clear_execution_context()? That probably cleans this up a little bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done!

args$catch.aborts <- NULL
}

# Try to resolve the file URI early so we can attribute plots to this
Copy link
Contributor

Choose a reason for hiding this comment

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

Since @lionel- is the author of this bit I think he should sign off on the changes in this file

@DavisVaughan DavisVaughan requested a review from lionel- March 2, 2026 17:24
Copy link
Contributor

@lionel- lionel- left a comment

Choose a reason for hiding this comment

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

That will be a nice feature!

The design is sound and the implementation looks nice.

Comment on lines +89 to +94
if (pushed) {
defer(tryCatch(
.ps.Call("ps_graphics_pop_source_context"),
error = function(e) NULL
))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: This could equivalently be run unconditionally in the try branch, after the .Call returns.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yep c1f9b56

Comment on lines +411 to +414
/// Checks three sources in priority order:
/// 1. Source context stack (inside `source("file.R")`) -- file-level, no range
/// 2. Execute request's `code_location` (code run from a file via the IDE) -- with range
/// 3. None (code typed at the console)
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

Comment on lines +1325 to +1340
/// Push a source file URI onto the source context stack.
/// Called from the `source()` hook when entering a sourced file.
#[harp::register]
unsafe extern "C-unwind" fn ps_graphics_push_source_context(uri: SEXP) -> anyhow::Result<SEXP> {
let uri_str: String = RObject::view(uri).try_into()?;
DEVICE_CONTEXT.with_borrow(|cell| cell.push_source_context(uri_str));
Ok(harp::r_null())
}

/// Pop a source file URI from the source context stack.
/// Called from the `source()` hook when leaving a sourced file.
#[harp::register]
unsafe extern "C-unwind" fn ps_graphics_pop_source_context() -> anyhow::Result<SEXP> {
DEVICE_CONTEXT.with_borrow(|cell| cell.pop_source_context());
Ok(harp::r_null())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

That makes sense to me, although I can't think of a reason to do it proactively.

I was initially thinking it may help with transitions of comms to Console thread, as the stack could be made available via the comm context. But the graphics device is not a comm so I don't think that will help there.

Feel free to add the source context to the Console though Jonathan.

In that case it might make sense to include the execute request's location in the stack so that priority resolution is consistently handled.


/// The origin (source) of a plot
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PlotOrigin {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since the structure is very similar to CodeLocation, I think I would have expected PlotLocation. I guess Origin has a slightly different nuance though, and indicates the original location might have changed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's hard to name this correctly! I think PlotLocation might be misread as "where the plot itself is located" vs "where the plot came from"

// Push execution context to graphics device for plot attribution
// Push execution context to graphics device for plot attribution.
// Extract code_location from the execute request for source file origin.
let code_location = exec_req.code_location().log_err().flatten().map(|mut loc| {
Copy link
Contributor

Choose a reason for hiding this comment

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

That's a good idea!

code_location() lives in Amalthea and ExtUrl::normalize() is in Ark, so a nice place to implement it would be on the ExtUrl extension trait:

let code_location = ExtUrl::from_execute_request(&exec_req);

"Metadata should contain kind 'plot', got:\n{result}"
);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice to see the new test infra put to good use!

I think it'd be worth testing the use case of sourcing a file A that sources a file B, to exercise the stacked aspect of locations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good idea! done in 08d50b8

@jmcphers jmcphers merged commit 778da3e into main Mar 3, 2026
8 checks passed
@jmcphers jmcphers deleted the feature/plot-file-attribution branch March 3, 2026 20:27
@github-actions github-actions bot locked and limited conversation to collaborators Mar 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants