Skip to content

[Phase 1] Enhance LSP Diagnostics with Broken Reference Detection #32

@vogella

Description

@vogella

Description

Enhance the LSP server's diagnostic capabilities to detect and report broken references in AsciiDoc documents. Currently, diagnostics only detect PLACEHOLDER_TEXT. We need real-time validation for broken includes, missing images, malformed syntax, and other common errors.

Current State

LSP Implementation (current - basic)

File: AsciidocTextDocumentService.java

Current Diagnostics:

  • Detects PLACEHOLDER_TEXT pattern only
  • Creates warnings with code action support

Missing:

  • Broken include:: references
  • Missing image files
  • Malformed syntax
  • Undefined attributes
  • Unclosed blocks

Required Changes

1. Enhance validateDocument Method

private void validateDocument(String uri) {
    AsciidocDocumentModel model = documentCache.get(uri);
    if (model == null) {
        return;
    }
    
    List<Diagnostic> diagnostics = new ArrayList<>();
    List<String> lines = model.getLines();
    
    for (int i = 0; i < lines.size(); i++) {
        String line = lines.get(i);
        
        // Validate includes
        diagnostics.addAll(validateIncludes(line, i, uri));
        
        // Validate images
        diagnostics.addAll(validateImages(line, i, uri));
        
        // Validate links
        diagnostics.addAll(validateLinks(line, i, uri));
        
        // Validate syntax
        diagnostics.addAll(validateSyntax(line, i));
        
        // Validate attributes
        diagnostics.addAll(validateAttributes(line, i, model));
    }
    
    // Validate block structure
    diagnostics.addAll(validateBlockStructure(lines));
    
    // Publish diagnostics
    publishDiagnostics(uri, diagnostics);
}

2. Validate Include References

private List<Diagnostic> validateIncludes(String line, int lineNum, String docUri) {
    List<Diagnostic> diagnostics = new ArrayList<>();
    Pattern pattern = Pattern.compile("include::(.*?)\\[");
    Matcher matcher = pattern.matcher(line);
    
    while (matcher.find()) {
        String includePath = matcher.group(1);
        
        // Resolve path
        Path docDir = Paths.get(URI.create(docUri)).getParent();
        Path includeFile = resolveIncludePath(docDir, includePath);
        
        if (!Files.exists(includeFile)) {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, matcher.start()),
                new Position(lineNum, matcher.end())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Error);
            diagnostic.setMessage("Include file not found: " + includePath);
            diagnostic.setCode("broken-include");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        } else if (!Files.isReadable(includeFile)) {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, matcher.start()),
                new Position(lineNum, matcher.end())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Warning);
            diagnostic.setMessage("Include file is not readable: " + includePath);
            diagnostic.setCode("unreadable-include");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        }
    }
    
    return diagnostics;
}

3. Validate Image References

private List<Diagnostic> validateImages(String line, int lineNum, String docUri) {
    List<Diagnostic> diagnostics = new ArrayList<>();
    Pattern pattern = Pattern.compile("image::(.*?)\\[");
    Matcher matcher = pattern.matcher(line);
    
    while (matcher.find()) {
        String imagePath = matcher.group(1);
        
        // Resolve in img/ subdirectory
        Path docDir = Paths.get(URI.create(docUri)).getParent();
        Path imageFile = docDir.resolve("img").resolve(imagePath);
        
        if (!Files.exists(imageFile)) {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, matcher.start()),
                new Position(lineNum, matcher.end())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Warning);
            diagnostic.setMessage("Image file not found: " + imagePath);
            diagnostic.setCode("missing-image");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        }
    }
    
    return diagnostics;
}

4. Validate Links

private List<Diagnostic> validateLinks(String line, int lineNum, String docUri) {
    List<Diagnostic> diagnostics = new ArrayList<>();
    Pattern pattern = Pattern.compile("link:(.*?)\\[");
    Matcher matcher = pattern.matcher(line);
    
    while (matcher.find()) {
        String target = matcher.group(1);
        
        // Skip external URLs
        if (target.startsWith("http://") || target.startsWith("https://")) {
            continue;
        }
        
        // Validate internal file links
        Path docDir = Paths.get(URI.create(docUri)).getParent();
        Path targetFile = resolveIncludePath(docDir, target);
        
        if (!Files.exists(targetFile)) {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, matcher.start()),
                new Position(lineNum, matcher.end())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Warning);
            diagnostic.setMessage("Link target not found: " + target);
            diagnostic.setCode("broken-link");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        }
    }
    
    return diagnostics;
}

5. Validate Syntax

private List<Diagnostic> validateSyntax(String line, int lineNum) {
    List<Diagnostic> diagnostics = new ArrayList<>();
    String trimmed = line.trim();
    
    // Check headers: should have space after '='
    if (trimmed.startsWith("=") && !trimmed.startsWith("====")) {
        int level = 0;
        while (level < trimmed.length() && trimmed.charAt(level) == '=') {
            level++;
        }
        
        if (level < trimmed.length() && trimmed.charAt(level) != ' ') {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, 0),
                new Position(lineNum, line.length())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Information);
            diagnostic.setMessage("Header should have space after '='");
            diagnostic.setCode("header-format");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        }
    }
    
    // Check for malformed attributes
    if (trimmed.startsWith(":") && !trimmed.endsWith(":")) {
        int colonPos = trimmed.indexOf(':', 1);
        if (colonPos == -1) {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, 0),
                new Position(lineNum, line.length())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Warning);
            diagnostic.setMessage("Attribute definition should end with ':'");
            diagnostic.setCode("malformed-attribute");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        }
    }
    
    // Check for unclosed inline formatting
    validateInlineFormatting(line, lineNum, diagnostics);
    
    return diagnostics;
}

private void validateInlineFormatting(String line, int lineNum, List<Diagnostic> diagnostics) {
    // Check for unclosed bold (*...*), italic (_..._), monospace (`...`)
    checkUnclosedDelimiter(line, lineNum, '*', "bold", diagnostics);
    checkUnclosedDelimiter(line, lineNum, '_', "italic", diagnostics);
    checkUnclosedDelimiter(line, lineNum, '`', "monospace", diagnostics);
}

private void checkUnclosedDelimiter(String line, int lineNum, char delimiter, 
                                     String formatName, List<Diagnostic> diagnostics) {
    int count = 0;
    for (char c : line.toCharArray()) {
        if (c == delimiter) count++;
    }
    
    if (count % 2 != 0) {
        Diagnostic diagnostic = new Diagnostic();
        diagnostic.setRange(new Range(
            new Position(lineNum, 0),
            new Position(lineNum, line.length())
        ));
        diagnostic.setSeverity(DiagnosticSeverity.Hint);
        diagnostic.setMessage("Unclosed " + formatName + " delimiter (" + delimiter + ")");
        diagnostic.setCode("unclosed-formatting");
        diagnostic.setSource("asciidoc");
        diagnostics.add(diagnostic);
    }
}

6. Validate Attributes

private List<Diagnostic> validateAttributes(String line, int lineNum, AsciidocDocumentModel model) {
    List<Diagnostic> diagnostics = new ArrayList<>();
    
    // Find attribute references {attribute-name}
    Pattern pattern = Pattern.compile("\\{([^}]+)\\}");
    Matcher matcher = pattern.matcher(line);
    
    // Extract defined attributes from document
    Set<String> definedAttributes = extractDefinedAttributes(model);
    
    while (matcher.find()) {
        String attrName = matcher.group(1);
        
        // Skip built-in attributes
        if (isBuiltInAttribute(attrName)) {
            continue;
        }
        
        if (!definedAttributes.contains(attrName)) {
            Diagnostic diagnostic = new Diagnostic();
            diagnostic.setRange(new Range(
                new Position(lineNum, matcher.start()),
                new Position(lineNum, matcher.end())
            ));
            diagnostic.setSeverity(DiagnosticSeverity.Information);
            diagnostic.setMessage("Undefined attribute: " + attrName);
            diagnostic.setCode("undefined-attribute");
            diagnostic.setSource("asciidoc");
            diagnostics.add(diagnostic);
        }
    }
    
    return diagnostics;
}

private Set<String> extractDefinedAttributes(AsciidocDocumentModel model) {
    Set<String> attributes = new HashSet<>();
    Pattern pattern = Pattern.compile("^:([^:]+):");
    
    for (String line : model.getLines()) {
        Matcher matcher = pattern.matcher(line.trim());
        if (matcher.find()) {
            attributes.add(matcher.group(1));
        }
    }
    
    return attributes;
}

private boolean isBuiltInAttribute(String name) {
    // Common built-in AsciiDoc attributes
    Set<String> builtIn = Set.of(
        "author", "email", "revdate", "revnumber", "revremark",
        "doctitle", "docdate", "doctime", "docdatetime",
        "localdate", "localtime", "localdatetime",
        "cpp", "blank", "sp", "nbsp", "zwsp", "wj",
        "apos", "quot", "lsquo", "rsquo", "ldquo", "rdquo",
        "deg", "plus", "brvbar", "vbar", "amp", "lt", "gt"
    );
    return builtIn.contains(name);
}

7. Validate Block Structure

private List<Diagnostic> validateBlockStructure(List<String> lines) {
    List<Diagnostic> diagnostics = new ArrayList<>();
    Stack<BlockInfo> blockStack = new Stack<>();
    
    for (int i = 0; i < lines.size(); i++) {
        String line = lines.get(i).trim();
        
        // Check for block delimiters
        if (line.equals("----")) {
            toggleBlock(blockStack, "code", i, "----", diagnostics);
        } else if (line.equals("====")) {
            toggleBlock(blockStack, "example", i, "====", diagnostics);
        } else if (line.equals("****")) {
            toggleBlock(blockStack, "sidebar", i, "****", diagnostics);
        } else if (line.equals("....")) {
            toggleBlock(blockStack, "literal", i, "....", diagnostics);
        } else if (line.equals("____")) {
            toggleBlock(blockStack, "quote", i, "____", diagnostics);
        } else if (line.equals("////")) {
            toggleBlock(blockStack, "comment", i, "////", diagnostics);
        }
    }
    
    // Check for unclosed blocks
    while (!blockStack.isEmpty()) {
        BlockInfo block = blockStack.pop();
        Diagnostic diagnostic = new Diagnostic();
        diagnostic.setRange(new Range(
            new Position(block.startLine, 0),
            new Position(block.startLine, block.delimiter.length())
        ));
        diagnostic.setSeverity(DiagnosticSeverity.Error);
        diagnostic.setMessage("Unclosed " + block.type + " block");
        diagnostic.setCode("unclosed-block");
        diagnostic.setSource("asciidoc");
        diagnostics.add(diagnostic);
    }
    
    return diagnostics;
}

private void toggleBlock(Stack<BlockInfo> stack, String type, int lineNum, 
                         String delimiter, List<Diagnostic> diagnostics) {
    if (!stack.isEmpty() && stack.peek().delimiter.equals(delimiter)) {
        stack.pop(); // Close block
    } else {
        stack.push(new BlockInfo(type, lineNum, delimiter)); // Open block
    }
}

private static class BlockInfo {
    String type;
    int startLine;
    String delimiter;
    
    BlockInfo(String type, int startLine, String delimiter) {
        this.type = type;
        this.startLine = startLine;
        this.delimiter = delimiter;
    }
}

Testing Checklist

Broken References

  • Include non-existent file - shows error
  • Include unreadable file - shows warning
  • Reference missing image - shows warning
  • Link to non-existent file - shows warning
  • Fix broken reference - diagnostic disappears

Syntax Validation

  • Header without space after '=' - shows hint
  • Malformed attribute definition - shows warning
  • Unclosed bold (*) - shows hint
  • Unclosed italic (_) - shows hint
  • Unclosed monospace (`) - shows hint

Attribute Validation

  • Reference undefined attribute - shows info
  • Built-in attributes don't trigger warning
  • Define attribute - references no longer flagged

Block Structure

  • Unclosed code block (----) - shows error
  • Unclosed example block (====) - shows error
  • Unclosed comment block (////) - shows error
  • Properly closed blocks - no errors

General

  • Diagnostics update on save/edit
  • Error markers appear in Problems view
  • Quick fixes work (if implemented)
  • Performance acceptable for large files

Files to Modify

  • com.vogella.lsp.asciidoc.server/src/.../AsciidocTextDocumentService.java

Dependencies

Success Criteria

  1. ✅ Broken includes detected and reported
  2. ✅ Missing images detected and reported
  3. ✅ Broken internal links detected
  4. ✅ Syntax errors detected (headers, attributes, formatting)
  5. ✅ Unclosed blocks detected
  6. ✅ Undefined attributes flagged (with built-in exclusions)
  7. ✅ Diagnostics update in real-time
  8. ✅ Performance acceptable (< 200ms for validation)

Estimated Effort

2-3 days (comprehensive validation rules and testing)

Priority

High - Prevents common errors and improves document quality

Related Issues

Future Enhancements

  • Code actions to fix diagnostics (create missing file, fix formatting, etc.)
  • Configurable severity levels (user preferences)
  • Custom validation rules via settings
  • Integration with Asciidoctor for deep validation

Notes

  • Consider performance impact of file system checks on every edit
  • May want to debounce validation (e.g., 500ms after last edit)
  • Path resolution should match completion/links for consistency
  • Be careful with false positives - better to under-report than over-report

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions