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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions