Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/sui-t36.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Added

- `StatifierUI.DatamodelExplorer.build_authoring/3` and `build_live/2` build
a read-only datamodel tree - document `<data id>` declarations, spec
5.10.1 system variables, predicator provider functions in scope, and
either a fixture scenario or a live session's datamodel with entries
marked `changed?` per macrostep - and
`StatifierUI.DatamodelExplorer.Markdown.render/2` renders it as Markdown
for `Kino.Markdown`.
1,010 changes: 1,010 additions & 0 deletions docs/plans/260822-sui-t36.7-datamodel-explorer-pane.md

Large diffs are not rendered by default.

594 changes: 594 additions & 0 deletions docs/research/260822-sui-t36.7-datamodel-explorer-pane.md

Large diffs are not rendered by default.

669 changes: 669 additions & 0 deletions lib/statifier_ui/datamodel_explorer.ex

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions lib/statifier_ui/datamodel_explorer/entry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
defmodule StatifierUI.DatamodelExplorer.Entry do
@moduledoc """
One node in the datamodel explorer tree (`sui-t36.7`): a `<data>`
declaration, a system variable, a provider function, a fixture scenario
value, or a runtime-only location - the ADR-0003 tiers, plus live mode's
`:runtime` sibling for a location `session.datamodel` never named.

Struct and types only - the way `StatifierUI.EventLog.Round` and
`StatifierUI.EventInjection.Entry` are - because both
`StatifierUI.DatamodelExplorer.Scope` (authoring and live alike) and
`sui-t36.8`'s renderer share this one shape.
"""

@typedoc """
Which of ADR-0003's tiers this entry belongs to. `:data` and `:runtime`
are both tier 1 - a document `<data id>` declaration and, in live mode
only, a location written at runtime that `session.datamodel`'s snapshot
did not name (an `<invoke idlocation>` or an empty `<finalize>`
auto-assign). `:system` is tier 2a (spec 5.10.1's system variables),
`:function` is tier 2b (predicator provider functions in scope), and
`:scenario` is tier 3 - the one tier that switches source between modes.
"""
@type tier :: :data | :system | :function | :scenario | :runtime

@typedoc """
* `value` - always a **decoded** Elixir value, never a `$`-tagged wire
term. This is the invariant that lets one `StatifierUI.Shape.infer/1`
pass serve both authoring and live mode.
* `changed?` - live-mode only; defaults to `false`. Authoring mode never
sets it.
* `arity` - set on `:function` entries only, predicator's
`non_neg_integer() | [non_neg_integer()]` multi-arity form verbatim.
* `declared_source` - the tier-1 declared expression text, display only.
Present only when the pane was given the chart source and
`value_location != location` (`docs/wire-format.md:335-348`).
* `location_source` - `effect.datamodel_change`'s raw author string,
live mode only, display only.
"""
@type t :: %__MODULE__{
name: String.t(),
tier: tier(),
value: term(),
shape: StatifierUI.Shape.t(),
label: String.t(),
changed?: boolean(),
children: [t()],
d_index: non_neg_integer() | nil,
arity: non_neg_integer() | [non_neg_integer()] | nil,
declared_source: String.t() | nil,
location_source: String.t() | nil
}

@enforce_keys [:name, :tier, :value, :shape, :label]
defstruct [
:name,
:tier,
:value,
:shape,
:label,
:d_index,
:arity,
:declared_source,
:location_source,
changed?: false,
children: []
]
end
165 changes: 165 additions & 0 deletions lib/statifier_ui/datamodel_explorer/markdown.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
defmodule StatifierUI.DatamodelExplorer.Markdown do
@moduledoc """
Renders a `StatifierUI.DatamodelExplorer.t()` as Markdown a host hands to
`Kino.Markdown.new/1`, per the `sui-t36.7` plan's Phase 4.

`render/2` is a pure function over the pane - it names `Kino` only in this
moduledoc and calls nothing under `Kino.*`. `sui-t36.8` owns wrapping the
returned string in an actual `Kino.Markdown` widget.

## Structure

A header line naming the mode and either the scenario (authoring) or the
session and macrostep (live), a drop warning when `truncated?`, then one
section per tier **in the pane's tier order** - `entries/1` already
produces its entries grouped contiguously by tier, so this module chunks
on that grouping rather than re-deriving or re-sorting it (ADR-0011). Each
section is a Markdown table of name, type label, and value; a `changed?`
entry's name carries the `:changed_marker` suffix (default `"*"`), and
each `Entry.children` element renders as its own row, qualified as
`"parent.child"`. A diagnostics section renders last, only when the pane
carries any.

Values render with `inspect/1` rather than re-encoded to JSON: this is a
debugging view of decoded Elixir terms, and `StatifierUI.Trace.Json` is
for the wire.
"""

alias StatifierUI.DatamodelExplorer
alias StatifierUI.DatamodelExplorer.Entry
alias StatifierUI.Fixtures
alias StatifierUI.Shape

@type opt ::
{:tiers, [Entry.tier()]}
| {:collapsible, boolean()}
| {:changed_marker, String.t()}
| {:max_keys, pos_integer()}
| {:max_depth, pos_integer()}

@doc """
Renders `pane` as a Markdown string.

Options:

* `:tiers` - when given, only entries whose tier is in this list get a
section; the other tiers' sections are omitted entirely rather than
rendered empty.
* `:collapsible` - wraps each tier section in `<details>`/`<summary>`
when `true`. Defaults to `false` - a plain `### <tier>` heading.
* `:changed_marker` - the suffix appended to a `changed?` entry's name.
Defaults to `"*"`.
* `:max_keys`, `:max_depth` - passed straight through to
`StatifierUI.Shape.label/2` for every entry's type label, so a value's
inferred shape can be rendered at a coarser grain than the entry was
built with.
"""
@spec render(DatamodelExplorer.t(), [opt()]) :: String.t()
def render(%DatamodelExplorer{} = pane, opts \\ []) do
changed_marker = Keyword.get(opts, :changed_marker, "*")
collapsible? = Keyword.get(opts, :collapsible, false)
shape_opts = Keyword.take(opts, [:max_keys, :max_depth])

entries = filtered_entries(pane, Keyword.get(opts, :tiers))

([header_block(pane)] ++
tier_blocks(entries, changed_marker, shape_opts, collapsible?) ++
diagnostics_block(pane))
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n\n")
end

# -- Entry selection ------------------------------------------------------

@spec filtered_entries(DatamodelExplorer.t(), [Entry.tier()] | nil) :: [Entry.t()]
defp filtered_entries(pane, nil), do: DatamodelExplorer.entries(pane)

defp filtered_entries(pane, tiers) do
Enum.filter(DatamodelExplorer.entries(pane), &(&1.tier in tiers))
end

# -- Header and truncation warning ----------------------------------------

@spec header_block(DatamodelExplorer.t()) :: String.t()
defp header_block(%DatamodelExplorer{mode: :authoring} = pane) do
lines = ["# Datamodel: authoring (scenario: #{pane.scenario || "(none)"})"]
Enum.join(lines ++ truncation_lines(pane), "\n")
end

defp header_block(%DatamodelExplorer{mode: :live} = pane) do
lines = [
"# Datamodel: live (session: #{pane.session || "(no session)"}, " <>
"macrostep: #{macrostep_text(pane.macrostep)})"
]

Enum.join(lines ++ truncation_lines(pane), "\n")
end

@spec macrostep_text(non_neg_integer() | nil) :: String.t()
defp macrostep_text(nil), do: "(none)"
defp macrostep_text(macrostep), do: to_string(macrostep)

@spec truncation_lines(DatamodelExplorer.t()) :: [String.t()]
defp truncation_lines(%DatamodelExplorer{truncated?: true}) do
["Earliest entries dropped; this fold does not start at the session's beginning."]
end

defp truncation_lines(%DatamodelExplorer{truncated?: false}), do: []

# -- Tier sections ---------------------------------------------------------

@spec tier_blocks([Entry.t()], String.t(), keyword(), boolean()) :: [String.t()]
defp tier_blocks(entries, changed_marker, shape_opts, collapsible?) do
entries
|> Enum.chunk_by(& &1.tier)
|> Enum.map(&tier_block(&1, changed_marker, shape_opts, collapsible?))
end

@spec tier_block([Entry.t()], String.t(), keyword(), boolean()) :: String.t()
defp tier_block([%Entry{tier: tier} | _] = entries, changed_marker, shape_opts, true) do
body = tier_table_lines(entries, changed_marker, shape_opts)

Enum.join(
["<details>", "<summary>#{tier}</summary>", ""] ++ body ++ ["</details>"],
"\n"
)
end

defp tier_block([%Entry{tier: tier} | _] = entries, changed_marker, shape_opts, false) do
body = tier_table_lines(entries, changed_marker, shape_opts)
Enum.join(["### #{tier}", ""] ++ body, "\n")
end

@spec tier_table_lines([Entry.t()], String.t(), keyword()) :: [String.t()]
defp tier_table_lines(entries, changed_marker, shape_opts) do
header = ["| name | type | value |", "| --- | --- | --- |"]
header ++ Enum.flat_map(entries, &entry_rows(&1, nil, changed_marker, shape_opts))
end

@spec entry_rows(Entry.t(), String.t() | nil, String.t(), keyword()) :: [String.t()]
defp entry_rows(%Entry{} = entry, prefix, changed_marker, shape_opts) do
name = qualified_name(prefix, entry.name)
marked_name = if entry.changed?, do: name <> changed_marker, else: name
type_label = Shape.label(entry.shape, shape_opts)
row = "| #{marked_name} | #{type_label} | #{inspect(entry.value)} |"

[row | Enum.flat_map(entry.children, &entry_rows(&1, name, changed_marker, shape_opts))]
end

@spec qualified_name(String.t() | nil, String.t()) :: String.t()
defp qualified_name(nil, name), do: name
defp qualified_name(prefix, name), do: "#{prefix}.#{name}"

# -- Diagnostics -----------------------------------------------------------

@spec diagnostics_block(DatamodelExplorer.t()) :: [String.t()]
defp diagnostics_block(%DatamodelExplorer{diagnostics: []}), do: []

defp diagnostics_block(%DatamodelExplorer{diagnostics: diagnostics}) do
lines = ["### Diagnostics" | Enum.map(diagnostics, &diagnostic_line/1)]
[Enum.join(lines, "\n")]
end

@spec diagnostic_line(Fixtures.diagnostic()) :: String.t()
defp diagnostic_line(%{kind: kind, message: message}), do: "- #{kind}: #{message}"
end
Loading
Loading