// 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.
///|
/// LSP-facing helper APIs for editor integrations.
///|
pub(all) enum CompletionKind {
Keyword
Style
Shape
Value
} derive(Eq, Debug)
///|
pub struct CompletionItem {
label : String
kind : CompletionKind
detail : String
insert_text : String
} derive(Eq, Debug)
///|
priv enum RefSelector {
Field(Array[String])
Edge(@ir.EdgeID)
}
///|
priv enum CompletionContext {
StyleProperties
ShapeValues
BooleanValues
FillPatternValues
TextTransformValues
NearValues
TooltipTemplate
IconTemplate
DirectionValues
ArrowheadProperties
ArrowheadShapes
LabelProperties
ValueHint(String)
}
///|
pub fn get_ref_ranges(
source : String,
key : String,
board_path? : Array[String] = [],
options? : ParseOptions = ParseOptions::new(),
) -> Array[@lexer.Range] raise DiagoError {
let ir_map = parse(source, options~)
let board_root = match find_map_by_path(ir_map, board_path) {
Some(m) => m
None =>
raise TargetError(
"Target board not found: \{@debug.to_string(board_path)}",
)
}
let selector = parse_ref_selector(key)
let ranges : Array[@lexer.Range] = []
match selector {
Field(path) =>
match find_field_by_path(board_root, path) {
Some(field) =>
for reference in field.references {
ranges.push(reference.range)
}
None => ()
}
Edge(edge_id) =>
for edge in board_root.find_edges(edge_id) {
for reference in edge.references {
ranges.push(reference.range)
}
}
}
ranges
}
///|
pub fn get_board_path_at_position(
source : String,
line : Int,
column : Int,
) -> Array[String] {
let (ast_map, _errors) = @compiler.parse(source)
match board_path_at_map(ast_map, [], line, column) {
Some(path) => path
None => []
}
}
///|
pub fn get_completion_items(
source : String,
line : Int,
column : Int,
) -> Array[CompletionItem] {
let prefix = line_prefix_at(source, line, column)
match detect_completion_context(prefix) {
Some(StyleProperties) => style_completion_items()
Some(ShapeValues) => shape_completion_items()
Some(BooleanValues) => boolean_completion_items()
Some(FillPatternValues) => fill_pattern_completion_items()
Some(TextTransformValues) => text_transform_completion_items()
Some(NearValues) => near_completion_items()
Some(TooltipTemplate) => tooltip_completion_items()
Some(IconTemplate) => icon_completion_items()
Some(DirectionValues) => direction_completion_items()
Some(ArrowheadProperties) => arrowhead_property_completion_items()
Some(ArrowheadShapes) => arrowhead_shape_completion_items()
Some(LabelProperties) => label_completion_items()
Some(ValueHint(property)) => [value_hint_completion_item(property)]
None => []
}
}
///|
fn parse_ref_selector(key : String) -> RefSelector raise DiagoError {
let selector_source = key + ": null"
let (selector_ast, selector_errors) = @compiler.parse(selector_source)
if !selector_errors.is_empty() {
raise ConfigError("invalid reference selector: \{key}")
}
if selector_ast.nodes.length() == 0 {
raise ConfigError("invalid reference selector: \{key}")
}
let map_key = match selector_ast.nodes[0] {
Key(k) => k
_ => raise ConfigError("invalid reference selector: \{key}")
}
if !map_key.edges.is_empty() {
let edge = map_key.edges[0]
let base = @ir.EdgeID::new(
edge.src.to_strings(),
edge.dst.to_strings(),
edge.src_arrow == "<",
edge.dst_arrow == ">",
)
let query_edge = match map_key.edge_index {
Some(edge_index) =>
if edge_index.glob {
@ir.EdgeID::with_glob(
base.src_path,
base.dst_path,
base.src_arrow,
base.dst_arrow,
)
} else {
@ir.EdgeID::with_index(
base.src_path,
base.dst_path,
base.src_arrow,
base.dst_arrow,
edge_index.index.unwrap_or(0),
)
}
None => base
}
Edge(query_edge)
} else {
match map_key.key {
Some(key_path) => {
if key_path.path.length() == 0 {
raise ConfigError("invalid reference selector: \{key}")
}
let path : Array[String] = []
for segment in key_path.path {
path.push(segment.content())
}
Field(path)
}
None => raise ConfigError("invalid reference selector: \{key}")
}
}
}
///|
fn find_map_by_path(root : @ir.Map, path : Array[String]) -> @ir.Map? {
if path.length() == 0 {
return Some(root)
}
let mut current = root
for segment in path {
match current.get_field(segment) {
Some(field) =>
match field.map() {
Some(next_map) => current = next_map
None => return None
}
None => return None
}
}
Some(current)
}
///|
fn find_field_by_path(root : @ir.Map, path : Array[String]) -> @ir.Field? {
if path.length() == 0 {
return None
}
let mut current = root
let mut last_field : @ir.Field? = None
for i, segment in path {
match current.get_field(segment) {
Some(field) => {
last_field = Some(field)
if i + 1 < path.length() {
match field.map() {
Some(next_map) => current = next_map
None => return None
}
}
}
None => return None
}
}
last_field
}
///|
fn board_path_at_map(
map : @ast.Map,
current_path : Array[String],
line : Int,
column : Int,
) -> Array[String]? {
if !position_in_range(map.range, line, column) {
return None
}
for node in map.nodes {
match node {
Key(key) =>
match (key.key, key.value) {
(Some(key_path), Some(value)) => {
if key_path.path.length() == 0 {
continue
}
match child_map_from_value(value) {
Some(child_map) => {
if !position_in_range(child_map.range, line, column) {
continue
}
let segment = key_path.path[0].content()
if current_path.length() % 2 == 0 && !is_board_keyword(segment) {
continue
}
let next_path = current_path.copy()
next_path.push(segment)
match board_path_at_map(child_map, next_path, line, column) {
Some(deeper_path) => return Some(deeper_path)
None =>
if next_path.length() % 2 == 0 {
return Some(next_path)
} else {
return None
}
}
}
None => ()
}
}
_ => ()
}
_ => ()
}
}
None
}
///|
fn child_map_from_value(value : @ast.Value) -> @ast.Map? {
match value {
Map(m) => Some(m)
BlockScalar(_, m) => Some(m)
_ => None
}
}
///|
fn is_board_keyword(segment : String) -> Bool {
segment == "layers" || segment == "scenarios" || segment == "steps"
}
///|
fn position_in_range(range : @lexer.Range, line : Int, column : Int) -> Bool {
let after_start = line > range.start.line ||
(line == range.start.line && column >= range.start.column)
let before_end = line < range.end.line ||
(line == range.end.line && column < range.end.column)
after_start && before_end
}
///|
fn line_prefix_at(source : String, line : Int, column : Int) -> String {
if line < 0 || column <= 0 {
return ""
}
let mut index = 0
for raw_line in source.split("\n") {
if index == line {
let line_text = raw_line.to_owned()
let end = if column < line_text.length() {
column
} else {
line_text.length()
}
return slice_string(line_text, 0, end)
}
index = index + 1
}
""
}
///|
fn detect_completion_context(prefix : String) -> CompletionContext? {
let normalized = prefix.trim().to_lower().to_owned()
if normalized.length() == 0 {
return None
}
if normalized.has_suffix("style.") || normalized.has_suffix("style:") {
return Some(StyleProperties)
}
if normalized.has_suffix("shape:") || normalized.has_suffix(".shape:") {
return Some(ShapeValues)
}
if normalized.has_suffix("fill-pattern:") ||
normalized.has_suffix("style.fill-pattern:") {
return Some(FillPatternValues)
}
if normalized.has_suffix("text-transform:") ||
normalized.has_suffix("style.text-transform:") {
return Some(TextTransformValues)
}
if normalized.has_suffix("source-arrowhead.shape:") ||
normalized.has_suffix("target-arrowhead.shape:") {
return Some(ArrowheadShapes)
}
if normalized.has_suffix("source-arrowhead.") ||
normalized.has_suffix("target-arrowhead.") {
return Some(ArrowheadProperties)
}
if normalized.has_suffix("label.") || normalized.has_suffix("icon.") {
return Some(LabelProperties)
}
if normalized.has_suffix("near:") ||
normalized.has_suffix("label.near:") ||
normalized.has_suffix("icon.near:") {
return Some(NearValues)
}
if normalized.has_suffix("tooltip:") {
return Some(TooltipTemplate)
}
if normalized.has_suffix("icon:") {
return Some(IconTemplate)
}
if normalized.has_suffix("direction:") {
return Some(DirectionValues)
}
if is_boolean_value_context(normalized) {
return Some(BooleanValues)
}
match detect_value_hint_property(normalized) {
Some(property) => Some(ValueHint(property))
None => None
}
}
///|
fn is_boolean_value_context(normalized : String) -> Bool {
let boolean_keys = [
"shadow", "3d", "multiple", "animated", "bold", "italic", "underline", "filled",
"double-border",
]
for key in boolean_keys {
if normalized.has_suffix("\{key}:") ||
normalized.has_suffix("style.\{key}:") {
return true
}
}
false
}
///|
fn detect_value_hint_property(normalized : String) -> String? {
let value_hint_keys = [
"opacity", "stroke-width", "stroke-dash", "border-radius", "font-size", "stroke",
"fill", "font-color", "width", "height", "top", "left",
]
for key in value_hint_keys {
if normalized.has_suffix("\{key}:") ||
normalized.has_suffix("style.\{key}:") {
return Some(key)
}
}
None
}
///|
fn style_completion_items() -> Array[CompletionItem] {
let items : Array[CompletionItem] = []
for keyword in @ast.style_keywords {
items.push({
label: keyword,
kind: Style,
detail: "style property",
insert_text: keyword + ": ",
})
}
items
}
///|
fn shape_completion_items() -> Array[CompletionItem] {
let shape_values = [
"rectangle", "square", "circle", "oval", "diamond", "hexagon", "parallelogram",
"cylinder", "queue", "package", "step", "callout", "stored_data", "person", "cloud",
"page", "document", "class", "sql_table", "text", "code", "image", "sequence_diagram",
"octagon", "c4_person", "c4_container",
]
completion_items_from_values(shape_values, Shape, "shape", false)
}
///|
fn boolean_completion_items() -> Array[CompletionItem] {
[
{ label: "true", kind: Value, detail: "boolean", insert_text: "true" },
{ label: "false", kind: Value, detail: "boolean", insert_text: "false" },
]
}
///|
fn fill_pattern_completion_items() -> Array[CompletionItem] {
completion_items_from_values(@ast.fill_patterns, Value, "fill pattern", false)
}
///|
fn text_transform_completion_items() -> Array[CompletionItem] {
completion_items_from_values(
@ast.text_transforms,
Value,
"text transform",
false,
)
}
///|
fn near_completion_items() -> Array[CompletionItem] {
let items : Array[CompletionItem] = [
{
label: "(object ID)",
kind: Value,
detail: "e.g. container.inner_shape",
insert_text: "",
},
]
for pos in @ast.label_positions {
items.push({
label: pos,
kind: Value,
detail: "label position",
insert_text: pos,
})
}
items
}
///|
fn tooltip_completion_items() -> Array[CompletionItem] {
[
{
label: "(markdown)",
kind: Value,
detail: "markdown formatted text",
insert_text: "|md\n # Tooltip\n Hello world\n|",
},
]
}
///|
fn icon_completion_items() -> Array[CompletionItem] {
[
{
label: "(URL, e.g. https://icons.terrastruct.com/xyz.svg)",
kind: Value,
detail: "icon URL",
insert_text: "https://icons.terrastruct.com/essentials%2F073-add.svg",
},
]
}
///|
fn direction_completion_items() -> Array[CompletionItem] {
completion_items_from_values(
["up", "down", "right", "left"],
Value,
"direction",
false,
)
}
///|
fn arrowhead_property_completion_items() -> Array[CompletionItem] {
[
{
label: "shape",
kind: Keyword,
detail: "arrowhead property",
insert_text: "shape: ",
},
{
label: "label",
kind: Keyword,
detail: "arrowhead property",
insert_text: "label: ",
},
{
label: "style.filled",
kind: Keyword,
detail: "arrowhead style property",
insert_text: "style.filled: ",
},
]
}
///|
fn arrowhead_shape_completion_items() -> Array[CompletionItem] {
completion_items_from_values(
[
"triangle", "arrow", "diamond", "circle", "cf-one", "cf-one-required", "cf-many",
"cf-many-required",
],
Shape,
"arrowhead shape",
false,
)
}
///|
fn label_completion_items() -> Array[CompletionItem] {
[
{
label: "near",
kind: Keyword,
detail: "label position",
insert_text: "near: ",
},
]
}
///|
fn value_hint_completion_item(property : String) -> CompletionItem {
let label = match property {
"opacity" => "(number between 0.0 and 1.0)"
"stroke-width" => "(number between 0 and 15)"
"font-size" => "(number between 8 and 100)"
"stroke-dash" => "(number between 0 and 10)"
"border-radius" => "(number greater than or equal to 0)"
"font-color" | "stroke" | "fill" => "(color name or hex code)"
"width" | "height" | "top" | "left" => "(pixels)"
_ => "(value)"
}
{ label, kind: Value, detail: "value hint", insert_text: "" }
}
///|
fn completion_items_from_values(
values : Array[String],
kind : CompletionKind,
detail : String,
append_colon : Bool,
) -> Array[CompletionItem] {
let items : Array[CompletionItem] = []
for value in values {
items.push({
label: value,
kind,
detail,
insert_text: if append_colon {
value + ": "
} else {
value
},
})
}
items
}
///|
fn slice_string(s : String, start : Int, end : Int) -> String {
let from = if start < 0 { 0 } else { start }
let to = if end < from {
from
} else if end > s.length() {
s.length()
} else {
end
}
let sb = StringBuilder::new()
for i in from..