// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Public facade API for parsing and compiling diago sources.
///|
pub(all) enum OutputMode {
Svg
Ascii
Unicode
} derive(Show, Eq)
///|
pub(all) enum LayoutEngine {
Auto
Dagre
Elk
Railway
} derive(Show, Eq)
///|
pub struct ParseOptions {
base_path : String?
import_resolver : @ir.ImportResolver?
}
///|
pub fn ParseOptions::new() -> ParseOptions {
{ base_path: None, import_resolver: None }
}
///|
pub fn ParseOptions::with_base_path(
self : ParseOptions,
base_path : String?,
) -> ParseOptions {
{ ..self, base_path, }
}
///|
pub fn ParseOptions::with_import_resolver(
self : ParseOptions,
import_resolver : @ir.ImportResolver?,
) -> ParseOptions {
{ ..self, import_resolver, }
}
///|
pub struct LayoutPlugin(
(@graph.GraphInput, @engine_api.LayoutConfig, @engine_api.Direction) -> Result[
@graph.GraphInput,
String,
]
)
///|
pub fn LayoutPlugin::new(
f : (@graph.GraphInput, @engine_api.LayoutConfig, @engine_api.Direction) -> Result[
@graph.GraphInput,
String,
],
) -> LayoutPlugin {
LayoutPlugin(f)
}
///|
pub fn LayoutPlugin::run(
self : LayoutPlugin,
graph : @graph.GraphInput,
config : @engine_api.LayoutConfig,
direction : @engine_api.Direction,
) -> Result[@graph.GraphInput, String] {
let LayoutPlugin(f) = self
f(graph, config, direction)
}
///|
pub struct RoutePlugin((@graph.GraphInput) -> Result[@graph.GraphInput, String])
///|
pub fn RoutePlugin::new(
f : (@graph.GraphInput) -> Result[@graph.GraphInput, String],
) -> RoutePlugin {
RoutePlugin(f)
}
///|
pub fn RoutePlugin::run(
self : RoutePlugin,
graph : @graph.GraphInput,
) -> Result[@graph.GraphInput, String] {
let RoutePlugin(f) = self
f(graph)
}
///|
pub struct CompileOptions {
output_mode : OutputMode
layout_engine : LayoutEngine
direction : @engine_api.Direction
target_spec : String?
layout_plugin : LayoutPlugin?
route_plugin : RoutePlugin?
parse_options : ParseOptions
theme_name : String?
dark_theme_id : Int?
theme_overrides : Map[String, String]
dark_theme_overrides : Map[String, String]
scale : Double?
no_xml_tag : Bool?
salt : String?
omit_version : Bool?
}
///|
pub fn CompileOptions::new() -> CompileOptions {
{
output_mode: Svg,
layout_engine: Auto,
direction: @engine_api.Down,
target_spec: None,
layout_plugin: None,
route_plugin: None,
parse_options: ParseOptions::new(),
theme_name: None,
dark_theme_id: None,
theme_overrides: {},
dark_theme_overrides: {},
scale: None,
no_xml_tag: None,
salt: None,
omit_version: None,
}
}
///|
pub fn CompileOptions::with_output_mode(
self : CompileOptions,
output_mode : OutputMode,
) -> CompileOptions {
{ ..self, output_mode, }
}
///|
pub fn CompileOptions::with_layout_engine(
self : CompileOptions,
layout_engine : LayoutEngine,
) -> CompileOptions {
{ ..self, layout_engine, }
}
///|
pub fn CompileOptions::with_direction(
self : CompileOptions,
direction : @engine_api.Direction,
) -> CompileOptions {
{ ..self, direction, }
}
///|
pub fn CompileOptions::with_target_spec(
self : CompileOptions,
target_spec : String?,
) -> CompileOptions {
{ ..self, target_spec, }
}
///|
pub fn CompileOptions::with_layout_plugin(
self : CompileOptions,
layout_plugin : LayoutPlugin?,
) -> CompileOptions {
{ ..self, layout_plugin, }
}
///|
pub fn CompileOptions::with_route_plugin(
self : CompileOptions,
route_plugin : RoutePlugin?,
) -> CompileOptions {
{ ..self, route_plugin, }
}
///|
pub fn CompileOptions::with_parse_options(
self : CompileOptions,
parse_options : ParseOptions,
) -> CompileOptions {
{ ..self, parse_options, }
}
///|
pub fn CompileOptions::with_theme_name(
self : CompileOptions,
theme_name : String?,
) -> CompileOptions {
{ ..self, theme_name, }
}
///|
pub fn CompileOptions::with_dark_theme_id(
self : CompileOptions,
dark_theme_id : Int?,
) -> CompileOptions {
{ ..self, dark_theme_id, }
}
///|
pub fn CompileOptions::with_theme_overrides(
self : CompileOptions,
theme_overrides : Map[String, String],
) -> CompileOptions {
{ ..self, theme_overrides, }
}
///|
pub fn CompileOptions::with_dark_theme_overrides(
self : CompileOptions,
dark_theme_overrides : Map[String, String],
) -> CompileOptions {
{ ..self, dark_theme_overrides, }
}
///|
pub fn CompileOptions::with_scale(
self : CompileOptions,
scale : Double?,
) -> CompileOptions {
{ ..self, scale, }
}
///|
pub fn CompileOptions::with_no_xml_tag(
self : CompileOptions,
no_xml_tag : Bool?,
) -> CompileOptions {
{ ..self, no_xml_tag, }
}
///|
pub fn CompileOptions::with_salt(
self : CompileOptions,
salt : String?,
) -> CompileOptions {
{ ..self, salt, }
}
///|
pub fn CompileOptions::with_omit_version(
self : CompileOptions,
omit_version : Bool?,
) -> CompileOptions {
{ ..self, omit_version, }
}
///|
pub(all) suberror DiagoError {
ParseError(String)
IOError(String)
IrError(String)
GraphError(String)
LayoutError(String)
RenderError(String)
ConfigError(String)
TargetError(String)
} derive(Eq, Show)
///|
pub fn parse(
source : String,
options? : ParseOptions = ParseOptions::new(),
) -> @ir.Map raise DiagoError {
let (ast, errors) = @compiler.parse(source)
if !errors.is_empty() {
raise ParseError("Parse errors: \{errors}")
}
let resolver = match options.import_resolver {
Some(resolver) => resolver
None => default_import_resolver(options.base_path)
}
@ir.compile_with_imports(ast, resolver) catch {
e => raise IrError(e.to_string())
}
}
///|
pub fn parse_file(
file_path : String,
options? : ParseOptions = ParseOptions::new(),
) -> @ir.Map raise DiagoError {
let source = @fs.read_file_to_string(file_path) catch {
e => raise IOError("Error reading file '\{file_path}': \{e}")
}
let resolved_options = if options.base_path is Some(_) {
options
} else {
{ ..options, base_path: get_dir_path(file_path) }
}
parse(source, options=resolved_options)
}
///|
pub fn compile_diagram(
source : String,
options? : CompileOptions = CompileOptions::new(),
) -> @diagram.Diagram raise DiagoError {
let ir_map = parse(source, options=options.parse_options)
let source_config = extract_source_config(ir_map)
let effective_layout_engine = resolve_layout_engine(
options.layout_engine,
source_config,
)
let graph_input = @exporter.to_graph("diagram", ir_map) catch {
e => raise GraphError(e.to_string())
}
let config = @engine_api.LayoutConfig::new()
let laid_out_graph = match options.layout_plugin {
Some(plugin) =>
match plugin.run(graph_input, config, options.direction) {
Ok(graph) => graph
Err(e) => raise LayoutError("Layout plugin error: \{e}")
}
None =>
match effective_layout_engine {
Auto | Dagre =>
@engine_api.layout_with_engine(
@engine_dagre.DagreEngine::new(),
graph_input,
config,
options.direction,
) catch {
e => raise LayoutError(e.to_string())
}
Elk =>
@engine_api.layout_with_engine(
@engine_elk.ElkEngine::new(),
graph_input,
config,
options.direction,
) catch {
e => raise LayoutError(e.to_string())
}
Railway =>
@engine_api.layout_with_engine(
@engine_railway.RailwayEngine::new(),
graph_input,
config,
options.direction,
) catch {
e => raise LayoutError(e.to_string())
}
}
}
let routed_graph = match options.route_plugin {
Some(plugin) =>
match plugin.run(laid_out_graph) {
Ok(graph) => graph
Err(e) => raise LayoutError("Route plugin error: \{e}")
}
None => laid_out_graph
}
let diagram = @diagram.from_graph(routed_graph, source_config)
match select_target_board(diagram, options.target_spec) {
Ok(board) => board
Err(e) => raise TargetError(e)
}
}
///|
pub fn compile(
source : String,
options? : CompileOptions = CompileOptions::new(),
) -> String raise DiagoError {
let diagram = compile_diagram(source, options~)
match options.output_mode {
Svg => {
let render_config = resolve_svg_config(options, diagram.config)
@renderer_svg.render(diagram, render_config) catch {
e => raise RenderError(e.to_string())
}
}
Ascii => @renderer_ascii.render(diagram, @renderer_ascii.AsciiConfig::new())
Unicode => @renderer_unicode.render(diagram)
}
}
///|
pub fn compile_file(
file_path : String,
options? : CompileOptions = CompileOptions::new(),
) -> String raise DiagoError {
let source = @fs.read_file_to_string(file_path) catch {
e => raise IOError("Error reading file '\{file_path}': \{e}")
}
let parse_options = if options.parse_options.base_path is Some(_) {
options.parse_options
} else {
{ ..options.parse_options, base_path: get_dir_path(file_path) }
}
let resolved_options = { ..options, parse_options, }
compile(source, options=resolved_options)
}
///|
fn resolve_layout_engine(
requested_layout_engine : LayoutEngine,
source_config : @diagram.Config?,
) -> LayoutEngine raise DiagoError {
if requested_layout_engine != Auto {
return requested_layout_engine
}
match source_config {
Some(config) =>
match config.layout_engine {
Some(layout_name) =>
match parse_layout_engine_name(layout_name) {
Some(engine) => engine
None =>
raise ConfigError(
"unsupported layout engine '\{layout_name}' from vars.d2-config.layout-engine. Expected dagre, elk, or railway.",
)
}
None => Dagre
}
None => Dagre
}
}
///|
fn parse_layout_engine_name(name : String) -> LayoutEngine? {
match name.to_lower() {
"auto" => Some(Auto)
"dagre" => Some(Dagre)
"elk" => Some(Elk)
"railway" => Some(Railway)
_ => None
}
}
///|
fn resolve_svg_config(
options : CompileOptions,
source_config : @diagram.Config?,
) -> @renderer_svg.SvgConfig {
let source_theme_id = match source_config {
Some(config) => config.theme_id
None => None
}
let source_dark_theme_id = match source_config {
Some(config) => config.dark_theme_id
None => None
}
let effective_dark_theme_id = match options.dark_theme_id {
Some(v) => Some(v)
None => source_dark_theme_id
}
let mut resolved = match options.theme_name {
Some(name) => @renderer_svg.SvgConfig::with_theme_name(name)
None =>
match source_theme_id {
Some(theme_id) => @renderer_svg.SvgConfig::with_theme(theme_id)
None => @renderer_svg.SvgConfig::new()
}
}
match source_config {
Some(config) =>
match config.pad {
Some(pad_value) => resolved = resolved.with_padding(pad_value)
None => ()
}
None => ()
}
let merged_theme_overrides : Map[String, String] = {}
let merged_dark_theme_overrides : Map[String, String] = {}
match source_config {
Some(config) => {
merge_theme_overrides(merged_theme_overrides, config.theme_overrides)
merge_theme_overrides(
merged_dark_theme_overrides,
config.dark_theme_overrides,
)
}
None => ()
}
merge_theme_overrides(merged_theme_overrides, options.theme_overrides)
merge_theme_overrides(
merged_dark_theme_overrides,
options.dark_theme_overrides,
)
let effective_scale = match options.scale {
Some(v) => Some(v)
None =>
match source_config {
Some(config) => config.scale
None => None
}
}
let effective_no_xml_tag = match options.no_xml_tag {
Some(v) => v
None =>
match source_config {
Some(config) => config.no_xml_tag.unwrap_or(false)
None => false
}
}
let effective_salt = match options.salt {
Some(v) => Some(v)
None =>
match source_config {
Some(config) => config.salt
None => None
}
}
let effective_omit_version = match options.omit_version {
Some(v) => v
None =>
match source_config {
Some(config) => config.omit_version.unwrap_or(false)
None => false
}
}
resolved.with_render_options(
effective_dark_theme_id, merged_theme_overrides, merged_dark_theme_overrides,
effective_scale, effective_no_xml_tag, effective_salt, effective_omit_version,
)
}
///|
fn merge_theme_overrides(
dst : Map[String, String],
src : Map[String, String],
) -> Unit {
for key, value in src {
dst[key] = value
}
}
///|
fn compile_theme_overrides_map(
overrides_map : @ir.Map,
) -> Result[Map[String, String], String] {
let overrides : Map[String, String] = {}
let errors : Array[String] = []
for field in overrides_map.fields {
if field.primary is Some(value) {
let raw_key = field.name
let key = raw_key.to_upper()
if not(is_valid_theme_override_key(key)) {
errors.push("\"\{raw_key}\" is not a valid theme code")
continue
}
let color_value = value.to_string()
if not(is_valid_theme_override_color(color_value)) {
errors.push(
"expected \"\{raw_key}\" to be a valid named color (\"orange\") or a hex code (\"#f0ff3a\")",
)
continue
}
overrides[key] = color_value
}
}
if not(errors.is_empty()) {
return Err(errors.join("; "))
}
Ok(overrides)
}
///|
fn is_valid_theme_override_key(key : String) -> Bool {
match key {
"N1"
| "N2"
| "N3"
| "N4"
| "N5"
| "N6"
| "N7"
| "B1"
| "B2"
| "B3"
| "B4"
| "B5"
| "B6"
| "AA2"
| "AA4"
| "AA5"
| "AB4"
| "AB5" => true
_ => false
}
}
///|
fn is_valid_theme_override_color(value : String) -> Bool {
is_css_named_color(value) || is_hex_color(value)
}
///|
fn is_css_named_color(value : String) -> Bool {
let lower = value.to_lower()
for color in css_named_colors {
if lower == color {
return true
}
}
false
}
///|
fn is_hex_color(value : String) -> Bool {
if not(value.has_prefix("#")) {
return false
}
let n = value.length()
if n != 4 && n != 7 {
return false
}
for i in 1.. if not(is_hex_digit(c)) { return false }
None => return false
}
}
true
}
///|
fn is_hex_digit(c : Char) -> Bool {
('0' <= c && c <= '9') || ('a' <= c && c <= 'f') || ('A' <= c && c <= 'F')
}
///|
let css_named_colors : Array[String] = [
"currentcolor", "transparent", "aliceblue", "antiquewhite", "aqua", "aquamarine",
"azure", "beige", "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown",
"burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue",
"cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray",
"darkgrey", "darkgreen", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange",
"darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategray",
"darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray",
"dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia",
"gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "grey", "green", "greenyellow",
"honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush",
"lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow",
"lightgray", "lightgrey", "lightgreen", "lightpink", "lightsalmon", "lightseagreen",
"lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow",
"lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue",
"mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen",
"mediumturquoise", "mediumvioletred", "midnightblue", "mintcream", "mistyrose",
"moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered",
"orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip",
"peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple", "red",
"rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell",
"sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen",
"steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat",
"white", "whitesmoke", "yellow", "yellowgreen",
]
///|
fn extract_source_config(ir_map : @ir.Map) -> @diagram.Config? raise DiagoError {
let vars_map = match ir_map.get_field("vars") {
Some(field) =>
match field.map() {
Some(map) => map
None => return None
}
None => return None
}
let config_map = match vars_map.get_field("d2-config") {
Some(field) =>
match field.map() {
Some(map) => map
None => return None
}
None => return None
}
let mut sketch : Bool? = None
let mut theme_id : Int? = None
let mut dark_theme_id : Int? = None
let theme_overrides : Map[String, String] = {}
let dark_theme_overrides : Map[String, String] = {}
let mut pad : Double? = None
let mut center : Bool? = None
let mut layout_engine : String? = None
let mut scale : Double? = None
let mut no_xml_tag : Bool? = None
let mut salt : String? = None
let mut omit_version : Bool? = None
let data : Map[String, String] = {}
if config_map.get_field("sketch") is Some(field) {
if field.primary is Some(value) {
sketch = value.as_bool()
}
}
if config_map.get_field("theme-id") is Some(field) {
if field.primary is Some(value) {
theme_id = value.as_int()
}
}
if config_map.get_field("dark-theme-id") is Some(field) {
if field.primary is Some(value) {
dark_theme_id = value.as_int()
}
}
if config_map.get_field("theme-overrides") is Some(theme_overrides_field) {
if theme_overrides_field.map() is Some(overrides_map) {
let overrides = match compile_theme_overrides_map(overrides_map) {
Ok(parsed) => parsed
Err(msg) => raise ConfigError(msg)
}
merge_theme_overrides(theme_overrides, overrides)
}
}
if config_map.get_field("dark-theme-overrides")
is Some(dark_theme_overrides_field) {
if dark_theme_overrides_field.map() is Some(overrides_map) {
let overrides = match compile_theme_overrides_map(overrides_map) {
Ok(parsed) => parsed
Err(msg) => raise ConfigError(msg)
}
merge_theme_overrides(dark_theme_overrides, overrides)
}
}
if config_map.get_field("pad") is Some(field) {
if field.primary is Some(value) {
if value.as_int() is Some(int_pad) {
pad = Some(int_pad.to_double())
}
}
}
if config_map.get_field("center") is Some(field) {
if field.primary is Some(value) {
center = value.as_bool()
}
}
if config_map.get_field("layout-engine") is Some(field) {
if field.primary is Some(value) {
layout_engine = Some(value.to_string())
}
}
if config_map.get_field("scale") is Some(field) {
if field.primary is Some(value) {
scale = value.as_double()
}
}
if config_map.get_field("no-xml-tag") is Some(field) {
if field.primary is Some(value) {
no_xml_tag = value.as_bool()
}
}
if config_map.get_field("salt") is Some(field) {
if field.primary is Some(value) {
salt = Some(value.to_string())
}
}
if config_map.get_field("omit-version") is Some(field) {
if field.primary is Some(value) {
omit_version = value.as_bool()
}
}
if config_map.get_field("data") is Some(data_field) {
if data_field.map() is Some(data_map) {
for field in data_map.fields {
if field.primary is Some(value) {
data[field.name] = value.to_string()
}
}
}
}
Some(
@diagram.Config::from_parts(
sketch, theme_id, dark_theme_id, theme_overrides, dark_theme_overrides, pad,
center, layout_engine, scale, no_xml_tag, salt, omit_version, data,
),
)
}
///|
fn select_target_board(
diagram : @diagram.Diagram,
target_spec : String?,
) -> Result[@diagram.Diagram, String] {
match target_spec {
None => Ok(diagram)
Some(spec) => {
let path = parse_target_path(spec)
if path.length() == 0 {
return Ok(diagram)
}
match diagram.get_board(path) {
Some(board) => Ok(board)
None => Err("Target board not found: \{spec}")
}
}
}
}
///|
fn parse_target_path(spec : String) -> Array[String] {
let segments : Array[String] = []
let current = StringBuilder::new()
for c in spec {
if c == '.' {
push_target_segment(segments, current.to_string())
current.reset()
} else {
current.write_char(c)
}
}
push_target_segment(segments, current.to_string())
segments
}
///|
fn push_target_segment(segments : Array[String], segment : String) -> Unit {
if segment.length() == 0 || segment == "*" {
return
}
segments.push(segment)
}
///|
fn default_import_resolver(base_path : String?) -> @ir.ImportResolver {
@ir.ImportResolver::new(fn(import_path) {
let full_path = match base_path {
Some(base) => @path.Path(base).join(@path.Path(import_path)).to_string()
None => import_path
}
let path_with_ext = if full_path.has_suffix(".d2") {
full_path
} else {
full_path + ".d2"
}
Some(@fs.read_file_to_string(path_with_ext)) catch {
_ => None
}
})
}
///|
fn get_dir_path(file_path : String) -> String? {
let path = @path.Path(file_path)
let dir = path.dirname()
let dir_str = dir.to_string()
if dir_str.length() > 0 {
Some(dir_str)
} else {
None
}
}