Skip to content

Commit 0805a37

Browse files
nlopesclaude
andcommitted
feat(lsp): add conditional directive awareness for ifdef/ifndef
Gray out inactive ifdef/ifndef branches based on defined document attributes, similar to how C/C++ editors gray out #ifdef inactive code. - Extract conditional blocks from raw text (following extract_includes pattern) - Add "disabled" semantic token modifier for inactive content - Emit DiagnosticTag::Unnecessary hints for universal editor graying - Highlight directive lines (ifdef/ifndef/endif) as keywords - Support both block form and single-line form - Handle OR (,) and AND (+) multi-attribute operations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a6d8e8f commit 0805a37

File tree

8 files changed

+590
-10
lines changed

8 files changed

+590
-10
lines changed

acdc-lsp/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Conditional directive awareness**`ifdef`/`ifndef` blocks are now detected
13+
and inactive branches are grayed out based on defined document attributes.
14+
Uses semantic tokens with a custom "disabled" modifier and `DiagnosticTag::Unnecessary`
15+
for universal editor support. Conditional directive lines are highlighted as keywords.
1216
- **Include path completion** — filesystem traversal completion for `include::`
1317
directives. Suggests files and directories as the user types the path, with
1418
AsciiDoc files prioritized. Selecting a directory re-triggers completion for

acdc-lsp/src/backend.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,11 @@ impl LanguageServer for Backend {
417417

418418
let response = if let Some(doc) = self.workspace.get_document(&uri) {
419419
doc.ast.as_ref().map(|ast| {
420-
SemanticTokensResult::Tokens(semantic_tokens::compute_semantic_tokens(ast))
420+
SemanticTokensResult::Tokens(semantic_tokens::compute_semantic_tokens(
421+
ast,
422+
&doc.conditionals,
423+
&doc.text,
424+
))
421425
})
422426
} else {
423427
None

acdc-lsp/src/capabilities/diagnostics.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ use std::hash::BuildHasher;
1010
use std::path::Path;
1111

1212
use acdc_parser::{Error, Location, Positioning, Source};
13-
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range};
13+
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, DiagnosticTag, Range};
14+
15+
use crate::state::{ConditionalBlock, ConditionalDirectiveKind, ConditionalOperation};
1416

1517
use crate::convert::{location_to_range, parser_position_to_lsp};
1618
use crate::state::XrefTarget;
@@ -180,6 +182,59 @@ pub fn compute_link_diagnostics(
180182
diagnostics
181183
}
182184

185+
/// Compute diagnostics for inactive conditional blocks.
186+
///
187+
/// Emits HINT-level diagnostics with `DiagnosticTag::UNNECESSARY` for content
188+
/// inside inactive ifdef/ifndef blocks. This causes editors to render the
189+
/// content with reduced opacity (grayed out).
190+
#[must_use]
191+
pub fn compute_conditional_diagnostics(conditionals: &[ConditionalBlock]) -> Vec<Diagnostic> {
192+
let mut diagnostics = Vec::new();
193+
194+
for cond in conditionals {
195+
if cond.is_active {
196+
continue;
197+
}
198+
199+
let Some(end_line) = cond.end_line else {
200+
continue;
201+
};
202+
203+
let directive = match cond.kind {
204+
ConditionalDirectiveKind::Ifdef => "ifdef",
205+
ConditionalDirectiveKind::Ifndef => "ifndef",
206+
};
207+
let separator = match cond.operation {
208+
Some(ConditionalOperation::Or) => ",",
209+
_ => "+",
210+
};
211+
let attrs = cond.attributes.join(separator);
212+
213+
let start_line: u32 = cond.start_line.try_into().unwrap_or(u32::MAX);
214+
let end_line_u32: u32 = end_line.try_into().unwrap_or(u32::MAX);
215+
216+
diagnostics.push(Diagnostic {
217+
range: Range {
218+
start: tower_lsp::lsp_types::Position {
219+
line: start_line,
220+
character: 0,
221+
},
222+
end: tower_lsp::lsp_types::Position {
223+
line: end_line_u32,
224+
character: u32::MAX,
225+
},
226+
},
227+
severity: Some(DiagnosticSeverity::HINT),
228+
source: Some("acdc".to_string()),
229+
message: format!("Inactive conditional block ({directive}::{attrs})"),
230+
tags: Some(vec![DiagnosticTag::UNNECESSARY]),
231+
..Default::default()
232+
});
233+
}
234+
235+
diagnostics
236+
}
237+
183238
#[cfg(test)]
184239
mod tests {
185240
use super::*;
@@ -393,4 +448,41 @@ mod tests {
393448
let diags = compute_link_diagnostics(&[], &includes, &tmp, None);
394449
assert!(diags.is_empty());
395450
}
451+
452+
#[test]
453+
fn test_conditional_inactive_diagnostic() -> Result<(), Box<dyn std::error::Error>> {
454+
let conditionals = vec![ConditionalBlock {
455+
kind: ConditionalDirectiveKind::Ifdef,
456+
attributes: vec!["missing-attr".to_string()],
457+
operation: None,
458+
is_active: false,
459+
start_line: 2,
460+
end_line: Some(4),
461+
}];
462+
463+
let diags = compute_conditional_diagnostics(&conditionals);
464+
assert_eq!(diags.len(), 1);
465+
let d = diags.first().ok_or("expected one diagnostic")?;
466+
assert_eq!(d.severity, Some(DiagnosticSeverity::HINT));
467+
assert!(d.message.contains("ifdef::missing-attr"));
468+
assert_eq!(d.tags, Some(vec![DiagnosticTag::UNNECESSARY]));
469+
assert_eq!(d.range.start.line, 2);
470+
assert_eq!(d.range.end.line, 4);
471+
Ok(())
472+
}
473+
474+
#[test]
475+
fn test_conditional_active_no_diagnostic() {
476+
let conditionals = vec![ConditionalBlock {
477+
kind: ConditionalDirectiveKind::Ifdef,
478+
attributes: vec!["present".to_string()],
479+
operation: None,
480+
is_active: true,
481+
start_line: 0,
482+
end_line: Some(2),
483+
}];
484+
485+
let diags = compute_conditional_diagnostics(&conditionals);
486+
assert!(diags.is_empty());
487+
}
396488
}

acdc-lsp/src/capabilities/formatting.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,7 @@ mod tests {
766766
attribute_refs: vec![],
767767
attribute_defs: vec![],
768768
media_sources: vec![],
769+
conditionals: vec![],
769770
};
770771

771772
let options = make_options();

acdc-lsp/src/capabilities/semantic_tokens.rs

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use tower_lsp::lsp_types::{
1717
WorkDoneProgressOptions,
1818
};
1919

20+
use crate::state::ConditionalBlock;
21+
2022
/// Convert usize to u32 for LSP types, saturating at `u32::MAX`.
2123
fn to_lsp_u32(val: usize) -> u32 {
2224
val.try_into().unwrap_or(u32::MAX)
@@ -37,8 +39,9 @@ pub const TOKEN_TYPES: &[SemanticTokenType] = &[
3739

3840
/// Semantic token modifiers
3941
pub const TOKEN_MODIFIERS: &[SemanticTokenModifier] = &[
40-
SemanticTokenModifier::DECLARATION, // anchor definitions
41-
SemanticTokenModifier::DEFINITION, // section with ID
42+
SemanticTokenModifier::DECLARATION, // 0 - anchor definitions
43+
SemanticTokenModifier::DEFINITION, // 1 - section with ID
44+
SemanticTokenModifier::new("disabled"), // 2 - inactive conditional content
4245
];
4346

4447
/// Create the semantic tokens legend for capability registration
@@ -70,12 +73,17 @@ struct RawToken {
7073
token_modifiers: u32,
7174
}
7275

73-
/// Compute semantic tokens for a document
76+
/// Compute semantic tokens for a document, including conditional directive awareness.
7477
#[must_use]
75-
pub fn compute_semantic_tokens(doc: &Document) -> SemanticTokens {
78+
pub fn compute_semantic_tokens(
79+
doc: &Document,
80+
conditionals: &[ConditionalBlock],
81+
text: &str,
82+
) -> SemanticTokens {
7683
let mut tokens: Vec<RawToken> = Vec::new();
7784

7885
collect_tokens_from_blocks(&doc.blocks, &mut tokens);
86+
collect_conditional_tokens(conditionals, text, &mut tokens);
7987

8088
// Sort by position for delta encoding
8189
tokens.sort_by(|a, b| a.line.cmp(&b.line).then(a.start_char.cmp(&b.start_char)));
@@ -89,6 +97,63 @@ pub fn compute_semantic_tokens(doc: &Document) -> SemanticTokens {
8997
}
9098
}
9199

100+
/// Emit semantic tokens for conditional directives and inactive content.
101+
fn collect_conditional_tokens(
102+
conditionals: &[ConditionalBlock],
103+
text: &str,
104+
tokens: &mut Vec<RawToken>,
105+
) {
106+
let lines: Vec<&str> = text.lines().collect();
107+
108+
for cond in conditionals {
109+
// Directive line as KEYWORD
110+
if let Some(line_text) = lines.get(cond.start_line)
111+
&& !line_text.is_empty()
112+
{
113+
tokens.push(RawToken {
114+
line: to_lsp_u32(cond.start_line),
115+
start_char: 0,
116+
length: to_lsp_u32(line_text.len()),
117+
token_type: 6, // KEYWORD
118+
token_modifiers: 0,
119+
});
120+
}
121+
122+
// Endif line as KEYWORD
123+
if let Some(endif_line) = cond.end_line
124+
&& let Some(line_text) = lines.get(endif_line)
125+
&& !line_text.is_empty()
126+
{
127+
tokens.push(RawToken {
128+
line: to_lsp_u32(endif_line),
129+
start_char: 0,
130+
length: to_lsp_u32(line_text.len()),
131+
token_type: 6, // KEYWORD
132+
token_modifiers: 0,
133+
});
134+
}
135+
136+
// For inactive blocks, emit COMMENT + disabled modifier for content lines
137+
if !cond.is_active
138+
&& let Some(end_line) = cond.end_line
139+
{
140+
for line_idx in (cond.start_line + 1)..end_line {
141+
if let Some(line_text) = lines.get(line_idx)
142+
&& !line_text.is_empty()
143+
{
144+
tokens.push(RawToken {
145+
line: to_lsp_u32(line_idx),
146+
start_char: 0,
147+
length: to_lsp_u32(line_text.len()),
148+
token_type: 5, // COMMENT
149+
token_modifiers: 4, // bit 2 = disabled
150+
});
151+
}
152+
}
153+
}
154+
}
155+
}
156+
92157
fn collect_tokens_from_blocks(blocks: &[Block], tokens: &mut Vec<RawToken>) {
93158
for block in blocks {
94159
collect_tokens_from_block(block, tokens);
@@ -370,7 +435,7 @@ Some content.
370435
let options = Options::default();
371436
let doc = acdc_parser::parse(content, &options)?;
372437

373-
let tokens = compute_semantic_tokens(&doc);
438+
let tokens = compute_semantic_tokens(&doc, &[], content);
374439
// Should have at least tokens for section titles
375440
assert!(!tokens.data.is_empty());
376441
Ok(())
@@ -388,7 +453,7 @@ See <<target>> for more.
388453
let options = Options::default();
389454
let doc = acdc_parser::parse(content, &options)?;
390455

391-
let tokens = compute_semantic_tokens(&doc);
456+
let tokens = compute_semantic_tokens(&doc, &[], content);
392457
// Should have tokens for section, anchor, and xref
393458
assert!(tokens.data.len() >= 2);
394459
Ok(())
@@ -400,5 +465,121 @@ See <<target>> for more.
400465
assert!(legend.token_types.contains(&SemanticTokenType::NAMESPACE));
401466
assert!(legend.token_types.contains(&SemanticTokenType::FUNCTION));
402467
assert!(legend.token_types.contains(&SemanticTokenType::COMMENT));
468+
assert_eq!(legend.token_modifiers.len(), 3);
469+
assert_eq!(
470+
legend.token_modifiers.get(2),
471+
Some(&SemanticTokenModifier::new("disabled"))
472+
);
473+
}
474+
475+
#[test]
476+
fn test_conditional_inactive_tokens() -> Result<(), acdc_parser::Error> {
477+
use crate::state::{ConditionalBlock, ConditionalDirectiveKind};
478+
479+
let content = "ifdef::missing[]\ninactive content\nendif::[]";
480+
let options = Options::default();
481+
let doc = acdc_parser::parse(content, &options)?;
482+
483+
let conditionals = vec![ConditionalBlock {
484+
kind: ConditionalDirectiveKind::Ifdef,
485+
attributes: vec!["missing".to_string()],
486+
operation: None,
487+
is_active: false,
488+
start_line: 0,
489+
end_line: Some(2),
490+
}];
491+
492+
let tokens = compute_semantic_tokens(&doc, &conditionals, content);
493+
// Should have tokens: ifdef line (KEYWORD), inactive content (COMMENT+disabled), endif (KEYWORD)
494+
assert!(tokens.data.len() >= 3);
495+
496+
// Reconstruct absolute positions from delta encoding
497+
let mut abs_tokens = Vec::new();
498+
let mut prev_line = 0u32;
499+
let mut prev_start = 0u32;
500+
for t in &tokens.data {
501+
let line = prev_line + t.delta_line;
502+
let start = if t.delta_line == 0 {
503+
prev_start + t.delta_start
504+
} else {
505+
t.delta_start
506+
};
507+
abs_tokens.push((
508+
line,
509+
start,
510+
t.length,
511+
t.token_type,
512+
t.token_modifiers_bitset,
513+
));
514+
prev_line = line;
515+
prev_start = start;
516+
}
517+
518+
// Line 0: ifdef directive → KEYWORD (type 6)
519+
assert!(
520+
abs_tokens.iter().any(|t| t.0 == 0 && t.3 == 6),
521+
"expected KEYWORD token on line 0"
522+
);
523+
// Line 1: inactive content → COMMENT (type 5) + disabled (bit 2 = 4)
524+
assert!(
525+
abs_tokens.iter().any(|t| t.0 == 1 && t.3 == 5 && t.4 == 4),
526+
"expected COMMENT+disabled token on line 1"
527+
);
528+
// Line 2: endif → KEYWORD (type 6)
529+
assert!(
530+
abs_tokens.iter().any(|t| t.0 == 2 && t.3 == 6),
531+
"expected KEYWORD token on line 2"
532+
);
533+
534+
Ok(())
535+
}
536+
537+
#[test]
538+
fn test_conditional_active_no_disabled_tokens() -> Result<(), acdc_parser::Error> {
539+
use crate::state::{ConditionalBlock, ConditionalDirectiveKind};
540+
541+
let content = "ifdef::present[]\nactive content\nendif::[]";
542+
let options = Options::default();
543+
let doc = acdc_parser::parse(content, &options)?;
544+
545+
let conditionals = vec![ConditionalBlock {
546+
kind: ConditionalDirectiveKind::Ifdef,
547+
attributes: vec!["present".to_string()],
548+
operation: None,
549+
is_active: true,
550+
start_line: 0,
551+
end_line: Some(2),
552+
}];
553+
554+
let tokens = compute_semantic_tokens(&doc, &conditionals, content);
555+
// Active block: should have KEYWORD tokens for directives but NO disabled tokens
556+
let mut abs_tokens = Vec::new();
557+
let mut prev_line = 0u32;
558+
let mut prev_start = 0u32;
559+
for t in &tokens.data {
560+
let line = prev_line + t.delta_line;
561+
let start = if t.delta_line == 0 {
562+
prev_start + t.delta_start
563+
} else {
564+
t.delta_start
565+
};
566+
abs_tokens.push((
567+
line,
568+
start,
569+
t.length,
570+
t.token_type,
571+
t.token_modifiers_bitset,
572+
));
573+
prev_line = line;
574+
prev_start = start;
575+
}
576+
577+
// No tokens should have the disabled modifier (bit 2 = 4)
578+
assert!(
579+
!abs_tokens.iter().any(|t| t.4 & 4 != 0),
580+
"active block should have no disabled tokens"
581+
);
582+
583+
Ok(())
403584
}
404585
}

0 commit comments

Comments
 (0)