LCOV - code coverage report
Current view: top level - dashql-core/src - vis_rewrite.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 98.4 % 129 127
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! Rewrite the visualization tags of a query: set, replace or remove its renderer (`@vis`) and its board
       2              : //! placement (`@board`), and the flow position of a script (`@flow`).
       3              : //!
       4              : //! The tags are edited in the annotation of the query (see `annotation`), so an edit leaves the annotation
       5              : //! consolidated in one comment. The edited tag is printed anew, the other tags keep their content. A new
       6              : //! renderer goes before the other `@vis` tags, a new board placement after the renderer or, without one, where a
       7              : //! renderer would go. Any statement can be placed on the board, only queries have a renderer. The tags of older
       8              : //! scripts, `@vis board`, `@vis card` and `@vis flow`, are replaced by `@board` and `@flow`. A statement that gains
       9              : //! or loses its renderer loses its board placement, the card moves to another section of the board. `/*` and `*/` in
      10              : //! the strings of a Vega-Lite spec are escaped, they would nest or end the comment.
      11              : 
      12              : use crate::annotation::{Annotation, AnnotationTag};
      13              : use crate::error::{Error, Result};
      14              : use dashql_buffers::parser::StatementType;
      15              : use dashql_buffers::status::StatusCode;
      16              : use dashql_parser::parser::ParsedScript;
      17              : use dashql_parser::span::TextSpan;
      18              : use serde_json::{Map, Value};
      19              : 
      20              : /// The tag of visualizations
      21              : const VIS_TAG: &str = "vis";
      22              : /// The tag of the dashboard placement of a visualization
      23              : const BOARD_TAG: &str = "board";
      24              : /// The tag of the position of a script on the dataflow canvas
      25              : const FLOW_TAG: &str = "flow";
      26              : /// The width of the JSON in `@vis vegalite` tags: 80 columns behind a comment prefix of 3 characters
      27              : pub(crate) const VIS_JSON_WIDTH: usize = 77;
      28              : /// The dashboard grid has 12 columns
      29              : const GRID_COLUMNS: i32 = 12;
      30              : 
      31              : /// The renderer of a visualization
      32              : #[derive(Clone, Debug, PartialEq)]
      33              : pub enum VisRenderer {
      34              :     /// A Vega-Lite spec without `data`
      35              :     VegaLite(Map<String, Value>),
      36              :     /// A Markdown row template
      37              :     Markdown(String),
      38              :     /// The result table
      39              :     Table,
      40              : }
      41              : 
      42              : impl VisRenderer {
      43              :     /// Read a renderer from its kind and its body, the body of `vegalite` is a JSON object
      44           10 :     pub fn from_parts(kind: &str, body: &str) -> Result<Self> {
      45            3 :         match kind {
      46           10 :             "vegalite" => match serde_json::from_str(body) {
      47            5 :                 Ok(Value::Object(spec)) => Ok(VisRenderer::VegaLite(spec)),
      48            1 :                 Ok(_) => Err(Error::invalid_argument("the Vega-Lite spec must be a JSON object")),
      49            0 :                 Err(e) => Err(Error::invalid_argument(format!("invalid Vega-Lite JSON: {e}"))),
      50              :             },
      51            4 :             "markdown" if body.trim().is_empty() => Err(Error::invalid_argument("a Markdown template must not be empty")),
      52            2 :             "markdown" => Ok(VisRenderer::Markdown(body.to_string())),
      53            1 :             "table" => Ok(VisRenderer::Table),
      54            1 :             _ => Err(Error::invalid_argument(format!("unknown @vis renderer \"{kind}\""))),
      55              :         }
      56           10 :     }
      57              : 
      58              :     /// The `@vis` tag of the renderer
      59           39 :     pub fn tag(&self) -> AnnotationTag {
      60           39 :         let lines = match self {
      61           29 :             VisRenderer::VegaLite(spec) => {
      62           29 :                 let json = crate::compact_json::to_string_compact(&Value::Object(spec.clone()), VIS_JSON_WIDTH);
      63              :                 // `/*` and `*/` can only be part of JSON strings, escapes split them without changing the
      64              :                 // value: `/*` would nest and `*/` would end a block comment
      65           29 :                 let json = json.replace("/*", "/\\u002a").replace("*/", "*\\/");
      66           29 :                 std::iter::once("vegalite".to_string()).chain(json.lines().map(str::to_string)).collect()
      67              :             }
      68            2 :             VisRenderer::Markdown(template) => {
      69            2 :                 std::iter::once("markdown".to_string()).chain(template.trim_end().lines().map(str::to_string)).collect()
      70              :             }
      71            8 :             VisRenderer::Table => vec!["table".to_string()],
      72              :         };
      73           39 :         AnnotationTag::new(VIS_TAG, lines)
      74           39 :     }
      75              : }
      76              : 
      77              : /// The dashboard placement of a visualization
      78              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      79              : pub struct VisBoard {
      80              :     /// The grid row, None for automatic placement
      81              :     pub row: Option<i32>,
      82              :     /// The grid column, None for automatic placement
      83              :     pub column: Option<i32>,
      84              :     /// The width in grid columns
      85              :     pub width: i32,
      86              :     /// The height in grid rows
      87              :     pub height: i32,
      88              : }
      89              : 
      90              : impl VisBoard {
      91              :     /// The `@board` tag. Negative rows are dropped, the width is limited to the grid, the column to the
      92              :     /// free space.
      93           27 :     pub fn tag(&self) -> AnnotationTag {
      94           27 :         let width = self.width.clamp(1, GRID_COLUMNS);
      95           27 :         let mut args = Vec::new();
      96           27 :         if let Some(row) = self.row.filter(|&r| r >= 0) {
      97            8 :             args.push(format!("row={row}"));
      98           19 :         }
      99           27 :         if let Some(column) = self.column {
     100            9 :             args.push(format!("column={}", column.clamp(0, GRID_COLUMNS - width)));
     101           21 :         }
     102           27 :         args.push(format!("width={width} height={}", self.height.max(1)));
     103           27 :         AnnotationTag::new(BOARD_TAG, vec![args.join(" ")])
     104           27 :     }
     105              : }
     106              : 
     107              : /// The placement of a script on the dataflow canvas: a position, a size, or both
     108              : #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
     109              : pub struct VisFlow {
     110              :     /// The x and y coordinates of the top left corner, None places the box automatically
     111              :     pub position: Option<(i32, i32)>,
     112              :     /// The width of the box, None for the default width
     113              :     pub width: Option<i32>,
     114              :     /// The height of the chart of the box, None for the default height
     115              :     pub height: Option<i32>,
     116              : }
     117              : 
     118              : impl VisFlow {
     119              :     /// The `@flow` tag, sizes are at least 1
     120            8 :     pub fn tag(&self) -> AnnotationTag {
     121            8 :         let mut args = Vec::new();
     122            8 :         if let Some((x, y)) = self.position {
     123            7 :             args.push(format!("x={x} y={y}"));
     124            7 :         }
     125            8 :         if let Some(width) = self.width {
     126            2 :             args.push(format!("width={}", width.max(1)));
     127            6 :         }
     128            8 :         if let Some(height) = self.height {
     129            2 :             args.push(format!("height={}", height.max(1)));
     130            6 :         }
     131            8 :         AnnotationTag::new(FLOW_TAG, vec![args.join(" ")])
     132            8 :     }
     133              : }
     134              : 
     135              : /// Is a tag a `@vis` tag?
     136          185 : fn is_vis(tag: &AnnotationTag) -> bool {
     137          185 :     tag.name == VIS_TAG
     138          185 : }
     139              : 
     140              : /// Is a tag a tag of the visualization: `@vis`, `@board` or `@flow`?
     141           15 : pub(crate) fn is_vis_family(tag: &AnnotationTag) -> bool {
     142           15 :     matches!(tag.name.as_str(), VIS_TAG | BOARD_TAG | FLOW_TAG)
     143           15 : }
     144              : 
     145              : /// Is a tag the renderer of a visualization?
     146          117 : fn is_renderer(tag: &AnnotationTag) -> bool {
     147          117 :     is_vis(tag) && matches!(tag.kind(), "vegalite" | "markdown" | "table")
     148          117 : }
     149              : 
     150              : /// Is a tag the dashboard placement of a visualization, `@board` or the `@vis board` of older scripts?
     151           63 : fn is_board(tag: &AnnotationTag) -> bool {
     152           63 :     tag.name == BOARD_TAG || (is_vis(tag) && matches!(tag.kind(), "board" | "card"))
     153           63 : }
     154              : 
     155              : /// Is a tag the flow position of a script, `@flow` or the `@vis flow` of older scripts?
     156           34 : fn is_flow(tag: &AnnotationTag) -> bool {
     157           34 :     tag.name == FLOW_TAG || (is_vis(tag) && tag.kind() == "flow")
     158           34 : }
     159              : 
     160              : /// Rewrite the annotation of a query
     161           52 : fn rewrite_query(
     162           52 :     text: &str,
     163           52 :     parsed: &ParsedScript,
     164           52 :     statement_id: usize,
     165           52 :     edit: impl FnOnce(&mut Annotation) -> Result<Option<usize>>,
     166           52 : ) -> Result<(String, Option<TextSpan>)> {
     167           52 :     if !parsed.errors.is_empty() {
     168            1 :         return Err(Error::status(StatusCode::SCRIPT_NOT_PARSED));
     169           51 :     }
     170           51 :     let statement = parsed.statements.get(statement_id).ok_or_else(|| Error::invalid_argument("unknown statement"))?;
     171           50 :     if statement.statement_type != StatementType::SELECT {
     172            1 :         return Err(Error::invalid_argument("@vis visualizes a query"));
     173           49 :     }
     174           49 :     crate::annotation::rewrite(text, parsed, statement_id, edit)
     175           52 : }
     176              : 
     177              : /// Set or remove the renderer of a query. Adding the first renderer or removing the last one also removes the board
     178              : /// placement. Returns the new text and the span of the written tag.
     179           52 : pub(crate) fn rewrite_vis_renderer_tag(
     180           52 :     text: &str,
     181           52 :     parsed: &ParsedScript,
     182           52 :     statement_id: usize,
     183           52 :     renderer: Option<&VisRenderer>,
     184           52 : ) -> Result<(String, Option<TextSpan>)> {
     185           52 :     rewrite_query(text, parsed, statement_id, |annotation| {
     186           49 :         let tags = &mut annotation.tags;
     187              :         // A statement that gains or loses its renderer moves between the sections of the board, its placement in the
     188              :         // section it leaves means nothing in the other one. The placement is dropped, the card flows into the free
     189              :         // space of its new section.
     190           49 :         if renderer.is_some() != tags.iter().any(is_renderer) {
     191           29 :             tags.retain(|tag| !is_board(tag));
     192           20 :         }
     193           49 :         Ok(match (renderer, tags.iter().position(is_renderer)) {
     194           20 :             (Some(renderer), Some(i)) => {
     195           20 :                 tags[i] = renderer.tag();
     196           20 :                 Some(i)
     197              :             }
     198           19 :             (Some(renderer), None) => {
     199           19 :                 let i = tags.iter().position(is_vis_family).unwrap_or(tags.len());
     200           19 :                 tags.insert(i, renderer.tag());
     201           19 :                 Some(i)
     202              :             }
     203           10 :             (None, Some(i)) => {
     204           10 :                 tags.remove(i);
     205           10 :                 None
     206              :             }
     207            0 :             (None, None) => None,
     208              :         })
     209           49 :     })
     210           52 : }
     211              : 
     212              : /// Set or remove the renderer of a query
     213           28 : pub fn rewrite_vis_renderer(text: &str, parsed: &ParsedScript, statement_id: usize, renderer: Option<&VisRenderer>) -> Result<String> {
     214           28 :     Ok(rewrite_vis_renderer_tag(text, parsed, statement_id, renderer)?.0)
     215           28 : }
     216              : 
     217              : /// Set or remove the board placement of a statement. Any statement is placed, a statement without a renderer is a
     218              : /// collapsed card on the board.
     219           30 : pub fn rewrite_vis_board(text: &str, parsed: &ParsedScript, statement_id: usize, board: Option<&VisBoard>) -> Result<String> {
     220           30 :     let (text, _) = crate::annotation::rewrite(text, parsed, statement_id, |annotation| {
     221           29 :         let tags = &mut annotation.tags;
     222           29 :         Ok(match (board, tags.iter().position(is_board)) {
     223            4 :             (Some(board), Some(i)) => {
     224            4 :                 tags[i] = board.tag();
     225            4 :                 Some(i)
     226              :             }
     227           23 :             (Some(board), None) => {
     228              :                 // After the renderer, or where a renderer would go
     229           23 :                 let i = match tags.iter().position(is_renderer) {
     230           19 :                     Some(renderer) => renderer + 1,
     231            4 :                     None => tags.iter().position(is_vis_family).unwrap_or(tags.len()),
     232              :                 };
     233           23 :                 tags.insert(i, board.tag());
     234           23 :                 Some(i)
     235              :             }
     236            1 :             (None, Some(i)) => {
     237            1 :                 tags.remove(i);
     238            1 :                 None
     239              :             }
     240            1 :             (None, None) => None,
     241              :         })
     242           29 :     })?;
     243           27 :     Ok(text)
     244           30 : }
     245              : 
     246              : /// Set or remove the flow position of a script. The position is a tag of any statement: the first `@flow` tag in
     247              : /// statement order is replaced where it is, the others are removed, and a new one goes after the visualization tags of
     248              : /// the terminal statement. Removing the position removes every `@flow` tag.
     249           11 : pub fn rewrite_vis_flow(text: &str, parsed: &ParsedScript, flow: Option<&VisFlow>) -> Result<String> {
     250           11 :     let insert_at = |tags: &[AnnotationTag]| tags.iter().rposition(is_vis_family).map_or(tags.len(), |i| i + 1);
     251           11 :     crate::annotation::rewrite_script_tag(text, parsed, is_flow, flow.map(VisFlow::tag), None, insert_at)
     252           11 : }
        

Generated by: LCOV version 2.0-1