use crate::ast::{
ChannelDefinition, ConditionalNode, Declaration, DiscoverStatement, EmitStatement,
EpistemicBlock, FlowDefinition, FlowStep, ForInStatement, ListenStep, Loc, Program,
PublishStatement,
};
use crate::tokens::Trivia;
fn doc_comment_lines(trivia: &[Trivia]) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for t in trivia {
if !t.is_doc() || t.is_inner_doc() {
continue;
}
for raw in t.stripped_text().lines() {
let trimmed = raw.strip_prefix(' ').unwrap_or(raw);
let trimmed = trimmed.strip_prefix("* ").unwrap_or(trimmed);
lines.push(trimmed.trim_end().to_string());
}
}
while lines.first().is_some_and(|l| l.is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelRefKind {
Emit,
EmitMobility,
Listen,
Publish,
Discover,
}
#[derive(Debug, Clone)]
pub struct ChannelReference {
pub channel_name: String,
pub kind: ChannelRefKind,
pub loc: Loc,
}
pub fn list_channels(program: &Program) -> Vec<&ChannelDefinition> {
let mut out = Vec::new();
collect_channels_in(&program.declarations, &mut out);
out
}
fn collect_channels_in<'a>(decls: &'a [Declaration], out: &mut Vec<&'a ChannelDefinition>) {
for decl in decls {
match decl {
Declaration::Channel(c) => out.push(c),
Declaration::Epistemic(eb) => {
let EpistemicBlock { body, .. } = eb;
collect_channels_in(body, out);
}
_ => {}
}
}
}
pub fn find_channel_definition<'a>(
program: &'a Program,
name: &str,
) -> Option<&'a ChannelDefinition> {
list_channels(program).into_iter().find(|c| c.name == name)
}
pub fn find_channel_references(program: &Program, name: &str) -> Vec<ChannelReference> {
let mut refs = Vec::new();
visit_decls(&program.declarations, name, &mut refs);
refs
}
fn visit_decls(decls: &[Declaration], target: &str, refs: &mut Vec<ChannelReference>) {
for decl in decls {
match decl {
Declaration::Daemon(d) => {
for listener in &d.listeners {
visit_listen(listener, target, refs);
}
}
Declaration::Flow(f) => visit_flow_body(&f.body, target, refs),
Declaration::Epistemic(eb) => visit_decls(&eb.body, target, refs),
_ => {}
}
}
}
fn visit_listen(listener: &ListenStep, target: &str, refs: &mut Vec<ChannelReference>) {
if listener.channel_is_ref && listener.channel == target {
refs.push(ChannelReference {
channel_name: target.to_string(),
kind: ChannelRefKind::Listen,
loc: listener.loc.clone(),
});
}
}
fn visit_flow_body(steps: &[FlowStep], target: &str, refs: &mut Vec<ChannelReference>) {
for step in steps {
visit_flow_step(step, target, refs);
}
}
fn visit_flow_step(step: &FlowStep, target: &str, refs: &mut Vec<ChannelReference>) {
match step {
FlowStep::Emit(EmitStatement {
channel_ref,
value_ref,
loc,
}) => {
if channel_ref == target {
refs.push(ChannelReference {
channel_name: target.to_string(),
kind: ChannelRefKind::Emit,
loc: loc.clone(),
});
}
if value_ref == target && channel_ref != target {
refs.push(ChannelReference {
channel_name: target.to_string(),
kind: ChannelRefKind::EmitMobility,
loc: loc.clone(),
});
}
}
FlowStep::Publish(PublishStatement {
channel_ref, loc, ..
}) => {
if channel_ref == target {
refs.push(ChannelReference {
channel_name: target.to_string(),
kind: ChannelRefKind::Publish,
loc: loc.clone(),
});
}
}
FlowStep::Discover(DiscoverStatement {
capability_ref,
loc,
..
}) => {
if capability_ref == target {
refs.push(ChannelReference {
channel_name: target.to_string(),
kind: ChannelRefKind::Discover,
loc: loc.clone(),
});
}
}
FlowStep::Listen(l) => visit_listen(l, target, refs),
FlowStep::If(ConditionalNode {
then_body,
else_body,
..
}) => {
visit_flow_body(then_body, target, refs);
visit_flow_body(else_body, target, refs);
}
FlowStep::ForIn(ForInStatement { body, .. }) => {
visit_flow_body(body, target, refs);
}
_ => {}
}
}
pub fn channel_hover_markdown(channel: &ChannelDefinition) -> String {
let mut buf = String::new();
let doc_lines = doc_comment_lines(&channel.leading_trivia);
if !doc_lines.is_empty() {
buf.push_str(&doc_lines.join("\n"));
buf.push_str("\n\n");
}
buf.push_str("```axon\n");
buf.push_str(&format!("channel {} {{\n", channel.name));
buf.push_str(&format!(" message: {}\n", channel.message));
buf.push_str(&format!(" qos: {}\n", channel.qos));
buf.push_str(&format!(" lifetime: {}\n", channel.lifetime));
buf.push_str(&format!(" persistence: {}\n", channel.persistence));
if !channel.shield_ref.is_empty() {
buf.push_str(&format!(" shield: {}\n", channel.shield_ref));
}
buf.push_str("}\n");
buf.push_str("```\n\n");
if channel.message.starts_with("Channel<") {
buf.push_str(
"**Second-order channel** — carries another channel handle as its \
message (π-calculus mobility, paper §3.2).\n\n",
);
}
if channel.shield_ref.is_empty() {
buf.push_str(
"_No shield declared._ This channel cannot be `publish`ed; \
declare `shield: <ShieldName>` to enable capability extrusion (D8).\n",
);
} else {
buf.push_str(&format!(
"Capability-gated by **`{}`** — `publish {} within {}` is the \
only legal extrusion path (D8, paper §3.4).\n",
channel.shield_ref, channel.name, channel.shield_ref,
));
}
buf
}
pub fn channel_names_in_scope(program: &Program) -> Vec<String> {
let mut names: Vec<String> = list_channels(program)
.iter()
.map(|c| c.name.clone())
.collect();
names.sort();
names
}
pub fn publishable_channel_names(program: &Program) -> Vec<String> {
let mut names: Vec<String> = list_channels(program)
.iter()
.filter(|c| !c.shield_ref.is_empty())
.map(|c| c.name.clone())
.collect();
names.sort();
names
}
pub fn channel_completion_detail(channel: &ChannelDefinition) -> String {
let publishable = if channel.shield_ref.is_empty() {
""
} else {
" · publishable"
};
format!(
"channel<{}, {}, {}>{}",
channel.message, channel.qos, channel.lifetime, publishable,
)
}
pub fn duplicate_channels(program: &Program) -> Vec<(String, Vec<&ChannelDefinition>)> {
use std::collections::BTreeMap;
let mut by_name: BTreeMap<&str, Vec<&ChannelDefinition>> = BTreeMap::new();
for c in list_channels(program) {
by_name.entry(&c.name).or_default().push(c);
}
by_name
.into_iter()
.filter(|(_, defs)| defs.len() > 1)
.map(|(k, v)| (k.to_string(), v))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelCompletionTrigger {
Listen,
Emit,
Publish,
Discover,
}
pub fn detect_channel_trigger(line_prefix: &str) -> Option<ChannelCompletionTrigger> {
let trimmed = line_prefix.trim_start();
for (kw, kind) in [
("listen", ChannelCompletionTrigger::Listen),
("emit", ChannelCompletionTrigger::Emit),
("publish", ChannelCompletionTrigger::Publish),
("discover", ChannelCompletionTrigger::Discover),
] {
if trimmed == kw || trimmed.starts_with(&format!("{kw} ")) {
return Some(kind);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lexer::Lexer;
use crate::parser::Parser;
fn parse(src: &str) -> Program {
let tokens = Lexer::new(src, "<test>").tokenize().expect("lex");
Parser::new(tokens).parse().expect("parse")
}
#[test]
fn list_channels_returns_in_source_order() {
let p = parse(
r#"
channel Beta { message: T }
channel Alpha { message: T }
channel Gamma { message: T }
"#,
);
let names: Vec<&str> = list_channels(&p).iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Beta", "Alpha", "Gamma"]);
}
#[test]
fn list_channels_descends_into_epistemic_blocks() {
let p = parse(
r#"
know { channel Inside { message: T } }
channel Outside { message: T }
"#,
);
let names: Vec<String> = list_channels(&p).iter().map(|c| c.name.clone()).collect();
assert!(names.contains(&"Inside".to_string()));
assert!(names.contains(&"Outside".to_string()));
}
#[test]
fn find_channel_definition_returns_node_with_loc() {
let p = parse("channel C { message: Order }");
let c = find_channel_definition(&p, "C").expect("found");
assert_eq!(c.name, "C");
assert!(c.loc.line > 0);
}
#[test]
fn find_channel_definition_unknown_returns_none() {
let p = parse("channel C { message: T }");
assert!(find_channel_definition(&p, "Bogus").is_none());
}
#[test]
fn find_references_emit_publish_discover_listen() {
let p = parse(
r#"
channel C { message: Order }
daemon D() {
goal: "x"
listen C as ev { }
}
flow f() -> O {
emit C(payload)
publish C within Gate
discover C as ch
}
"#,
);
let refs = find_channel_references(&p, "C");
let kinds: Vec<ChannelRefKind> = refs.iter().map(|r| r.kind).collect();
assert!(kinds.contains(&ChannelRefKind::Listen));
assert!(kinds.contains(&ChannelRefKind::Emit));
assert!(kinds.contains(&ChannelRefKind::Publish));
assert!(kinds.contains(&ChannelRefKind::Discover));
}
#[test]
fn find_references_distinguishes_mobility_from_emit() {
let p = parse(
r#"
channel Inner { message: Order }
channel Outer { message: Channel<Order> }
flow f() -> O { emit Outer(Inner) }
"#,
);
let refs_inner = find_channel_references(&p, "Inner");
assert_eq!(refs_inner.len(), 1);
assert_eq!(refs_inner[0].kind, ChannelRefKind::EmitMobility);
let refs_outer = find_channel_references(&p, "Outer");
assert_eq!(refs_outer.len(), 1);
assert_eq!(refs_outer[0].kind, ChannelRefKind::Emit);
}
#[test]
fn find_references_skips_legacy_string_topics() {
let p = parse(
r#"
channel C { message: Order }
daemon D() {
goal: "x"
listen "C" as ev { }
}
"#,
);
let refs = find_channel_references(&p, "C");
assert!(
refs.is_empty(),
"string topics must not appear in channel references: {:?}",
refs
);
}
#[test]
fn find_references_descends_into_conditionals_and_for_loops() {
let p = parse(
r#"
channel C { message: T }
flow f() -> O {
if x == 1 {
emit C(p)
} else {
publish C within Gate
}
for item in items {
discover C as ch
}
}
"#,
);
let refs = find_channel_references(&p, "C");
let kinds: Vec<ChannelRefKind> = refs.iter().map(|r| r.kind).collect();
assert!(kinds.contains(&ChannelRefKind::Emit));
assert!(kinds.contains(&ChannelRefKind::Publish));
assert!(kinds.contains(&ChannelRefKind::Discover));
}
#[test]
fn hover_includes_signature_block() {
let p = parse("channel C { message: Order qos: exactly_once shield: G }");
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.contains("```axon"));
assert!(md.contains("channel C"));
assert!(md.contains("message: Order"));
assert!(md.contains("qos: exactly_once"));
assert!(md.contains("shield: G"));
}
#[test]
fn hover_flags_second_order_channel() {
let p = parse("channel C { message: Channel<Order> }");
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.contains("Second-order channel"));
assert!(md.contains("paper §3.2"));
}
#[test]
fn hover_explains_publish_gate_when_present() {
let p = parse("channel C { message: T shield: Gate }");
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.contains("Capability-gated by"));
assert!(md.contains("Gate"));
}
#[test]
fn hover_warns_when_shield_missing() {
let p = parse("channel C { message: T }");
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.contains("No shield declared"));
assert!(md.contains("D8"));
}
#[test]
fn channel_names_in_scope_are_sorted() {
let p = parse(
r#"
channel Zulu { message: T }
channel Alpha { message: T }
channel Mike { message: T }
"#,
);
assert_eq!(
channel_names_in_scope(&p),
vec!["Alpha".to_string(), "Mike".to_string(), "Zulu".to_string()],
);
}
#[test]
fn publishable_filter_excludes_shieldless_channels() {
let p = parse(
r#"
channel Public { message: T shield: Gate }
channel Private { message: T }
"#,
);
assert_eq!(publishable_channel_names(&p), vec!["Public".to_string()]);
}
#[test]
fn completion_detail_marks_publishable_channels() {
let p = parse("channel C { message: Order qos: exactly_once shield: Gate }");
let c = find_channel_definition(&p, "C").unwrap();
let detail = channel_completion_detail(c);
assert!(detail.contains("Order"));
assert!(detail.contains("exactly_once"));
assert!(detail.contains("publishable"));
}
#[test]
fn completion_detail_omits_publishable_when_no_shield() {
let p = parse("channel C { message: T }");
let c = find_channel_definition(&p, "C").unwrap();
let detail = channel_completion_detail(c);
assert!(!detail.contains("publishable"));
}
#[test]
fn duplicate_channels_detected() {
let p = parse(
r#"
channel C { message: T }
channel C { message: U }
"#,
);
let dups = duplicate_channels(&p);
assert_eq!(dups.len(), 1);
assert_eq!(dups[0].0, "C");
assert_eq!(dups[0].1.len(), 2);
}
#[test]
fn hover_prepends_outer_doc_line_comment() {
let p = parse(
"/// Inbound order events from the broker.\nchannel C { message: Order shield: Gate }",
);
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
let doc_pos = md.find("Inbound order events").expect("doc text missing");
let sig_pos = md.find("```axon").expect("signature missing");
assert!(doc_pos < sig_pos, "doc must come before signature: {md}");
}
#[test]
fn hover_renders_multiple_outer_doc_lines_as_paragraph() {
let src = "/// Line one.\n/// Line two.\nchannel C { message: T shield: Gate }";
let p = parse(src);
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.contains("Line one."));
assert!(md.contains("Line two."));
}
#[test]
fn hover_renders_outer_doc_block_comment() {
let src = "/** Block-form docs for C. */\nchannel C { message: T shield: Gate }";
let p = parse(src);
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.contains("Block-form docs for C."));
}
#[test]
fn hover_omits_inner_doc_comment() {
let src = "//! file-level docs that should not leak\nchannel C { message: T shield: Gate }";
let p = parse(src);
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(
!md.contains("file-level docs that should not leak"),
"inner doc must not appear in channel hover: {md}",
);
}
#[test]
fn hover_omits_regular_line_comment() {
let src = "// internal note that should not surface\nchannel C { message: T shield: Gate }";
let p = parse(src);
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(
!md.contains("internal note that should not surface"),
"regular comment leaked into hover: {md}",
);
}
#[test]
fn hover_with_no_doc_comment_unchanged() {
let p = parse("channel C { message: Order shield: Gate }");
let c = find_channel_definition(&p, "C").unwrap();
let md = channel_hover_markdown(c);
assert!(md.starts_with("```axon\n"));
}
#[test]
fn duplicate_channels_empty_when_unique() {
let p = parse(
r#"
channel A { message: T }
channel B { message: T }
"#,
);
assert!(duplicate_channels(&p).is_empty());
}
#[test]
fn detect_trigger_recognizes_each_keyword() {
for (line, expected) in [
(" listen ", Some(ChannelCompletionTrigger::Listen)),
("emit ", Some(ChannelCompletionTrigger::Emit)),
(" publish ", Some(ChannelCompletionTrigger::Publish)),
("discover ", Some(ChannelCompletionTrigger::Discover)),
("listen", Some(ChannelCompletionTrigger::Listen)),
] {
assert_eq!(detect_channel_trigger(line), expected, "line {line:?}");
}
}
#[test]
fn detect_trigger_returns_none_outside_keyword() {
assert_eq!(detect_channel_trigger("step S {"), None);
assert_eq!(detect_channel_trigger("flow f() ->"), None);
assert_eq!(detect_channel_trigger(""), None);
}
}