Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/mdschema/commands/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ func runGenerate(cfg *Config, schemaFile, outputFile string) error {
var err error

if schemaFile != "" {
s, err = schema.Load(schemaFile)
var warnings []schema.Warning
s, warnings, err = schema.Load(schemaFile)
if err != nil {
return fmt.Errorf("loading schema from %s: %w", schemaFile, err)
}
printSchemaWarnings(schemaFile, warnings)
} else {
// Load schema using existing utility
s, _, err = loadSchema(cfg)
Expand Down
36 changes: 24 additions & 12 deletions cmd/mdschema/commands/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,38 @@ import (

// loadSchema loads a schema based on config or discovery
func loadSchema(cfg *Config) (*schema.Schema, string, error) {
var loaded *schema.Schema
var warnings []schema.Warning
var path string
var err error

if cfg.SchemaFile != "" {
// Load explicitly specified schema
loaded, err := schema.Load(cfg.SchemaFile)
loaded, warnings, err = schema.Load(cfg.SchemaFile)
if err != nil {
return nil, "", fmt.Errorf("loading schema %s: %w", cfg.SchemaFile, err)
}
return loaded, cfg.SchemaFile, nil
path = cfg.SchemaFile
} else {
schemaPath, ferr := schema.FindSchema(".")
if ferr != nil {
return nil, "", fmt.Errorf("finding schema: %w", ferr)
}
loaded, warnings, err = schema.Load(schemaPath)
if err != nil {
return nil, "", fmt.Errorf("loading discovered schema: %w", err)
}
path = schemaPath
}

// Try to discover schema in current directory
schemaPath, err := schema.FindSchema(".")
if err != nil {
return nil, "", fmt.Errorf("finding schema: %w", err)
}
printSchemaWarnings(path, warnings)
return loaded, path, nil
}

loaded, err := schema.Load(schemaPath)
if err != nil {
return nil, "", fmt.Errorf("loading discovered schema: %w", err)
// printSchemaWarnings prints non-fatal schema load warnings to stderr.
func printSchemaWarnings(path string, warnings []schema.Warning) {
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "warning: %s: %s at line %d\n", path, w.Message, w.Line)
}
return loaded, schemaPath, nil
}

const (
Expand Down
2 changes: 1 addition & 1 deletion internal/integration/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func runTestCases(t *testing.T, testCases []TestCase) {
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
// Load schema
s, err := schema.Load(tc.SchemaPath)
s, _, err := schema.Load(tc.SchemaPath)
if err != nil {
t.Fatalf("Failed to load schema %s: %v", tc.SchemaPath, err)
}
Expand Down
27 changes: 27 additions & 0 deletions internal/integration/paragraph_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package integration

import "testing"

// TestParagraphValidation tests paragraph rule validation scenarios
func TestParagraphValidation(t *testing.T) {
testCases := []TestCase{
// Valid cases
{
Name: "valid section with paragraph",
FilePath: testdataDir + "paragraph/valid_has_paragraph.md",
SchemaPath: testdataDir + "paragraph/.mdschema.yml",
ShouldPass: true,
},

// Invalid cases
{
Name: "Overview with only a bullet list, no paragraph",
FilePath: testdataDir + "paragraph/invalid_no_paragraph.md",
SchemaPath: testdataDir + "paragraph/.mdschema.yml",
ShouldPass: false,
ExpectedRule: "paragraph",
},
}

runTestCases(t, testCases)
}
1 change: 1 addition & 0 deletions internal/jsonschema/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func Generate() ([]byte, error) {
{"TableRule", reflect.TypeOf(schema.TableRule{})},
{"ListRule", reflect.TypeOf(schema.ListRule{})},
{"WordCountRule", reflect.TypeOf(schema.WordCountRule{})},
{"ParagraphRule", reflect.TypeOf(schema.ParagraphRule{})},
{"CountConstraint", reflect.TypeOf(schema.CountConstraint{})},
}

Expand Down
8 changes: 8 additions & 0 deletions internal/parser/extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ func extractList(node *ast.List, content []byte) *List {
return list
}

func extractParagraph(node *ast.Paragraph, content []byte) *Paragraph {
line, col := getPosition(node, content)
return &Paragraph{
Line: line,
Column: col,
}
}

func extractTable(node *east.Table, content []byte) *Table {
headers := make([]string, 0)

Expand Down
15 changes: 9 additions & 6 deletions internal/parser/hierarchy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package parser
import "strings"

// buildHierarchicalSections creates a hierarchical tree of sections
func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, content []byte) *Section {
func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, paragraphs []*Paragraph, content []byte) *Section {
// Create root section for top-level content
contentLineCount := len(strings.Split(string(content), "\n"))
root := &Section{
Expand All @@ -17,6 +17,7 @@ func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*Co
Links: make([]*Link, 0),
Images: make([]*Image, 0),
Lists: make([]*List, 0),
Paragraphs: make([]*Paragraph, 0),
}

// Stack to track current nesting level
Expand All @@ -34,6 +35,7 @@ func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*Co
Links: make([]*Link, 0),
Images: make([]*Image, 0),
Lists: make([]*List, 0),
Paragraphs: make([]*Paragraph, 0),
}

// Find appropriate parent based on heading level
Expand All @@ -56,20 +58,20 @@ func (p *Parser) buildHierarchicalSections(headings []*Heading, codeBlocks []*Co
}

// Second pass: set end lines and associate content
p.setEndLinesAndContent(root, codeBlocks, tables, links, images, lists, content)
p.setEndLinesAndContent(root, codeBlocks, tables, links, images, lists, paragraphs, content)

return root
}

// setEndLinesAndContent sets end lines first, then associates content with sections
func (p *Parser) setEndLinesAndContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, content []byte) {
func (p *Parser) setEndLinesAndContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, paragraphs []*Paragraph, content []byte) {
contentLines := strings.Split(string(content), "\n")

// First pass: set all end lines correctly (bottom-up via recursion)
p.setEndLines(section, len(contentLines))

// Second pass: associate elements and extract content
p.associateContent(section, codeBlocks, tables, links, images, lists, contentLines)
p.associateContent(section, codeBlocks, tables, links, images, lists, paragraphs, contentLines)
}

// setEndLines recursively sets end lines for all sections (top-down)
Expand Down Expand Up @@ -109,7 +111,7 @@ func (p *Parser) findNextSiblingStart(section *Section) int {
}

// associateContent recursively extracts content and associates elements with sections
func (p *Parser) associateContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, contentLines []string) {
func (p *Parser) associateContent(section *Section, codeBlocks []*CodeBlock, tables []*Table, links []*Link, images []*Image, lists []*List, paragraphs []*Paragraph, contentLines []string) {
// Extract content
if section.StartLine > 0 && section.EndLine <= len(contentLines) {
sectionContent := make([]string, 0)
Expand All @@ -131,10 +133,11 @@ func (p *Parser) associateContent(section *Section, codeBlocks []*CodeBlock, tab
section.Links = filterElements(section, links)
section.Images = filterElements(section, images)
section.Lists = filterElements(section, lists)
section.Paragraphs = filterElements(section, paragraphs)

// Recursively process children
for _, child := range section.Children {
p.associateContent(child, codeBlocks, tables, links, images, lists, contentLines)
p.associateContent(child, codeBlocks, tables, links, images, lists, paragraphs, contentLines)
}
}

Expand Down
56 changes: 56 additions & 0 deletions internal/parser/paragraph_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package parser

import "testing"

func TestParagraphsTopLevelOnly(t *testing.T) {
src := []byte(`# Title

## Overview

First prose paragraph.

Second prose paragraph.

- a bullet

- another bullet

> a blockquote paragraph
`)
p := New()
doc, err := p.Parse("test.md", src)
if err != nil {
t.Fatalf("Parse() error: %v", err)
}

var overview *Section
for _, s := range doc.GetSections() {
if s.Heading != nil && s.Heading.Text == "Overview" {
overview = s
}
}
if overview == nil {
t.Fatal("Overview section not found")
}

// Two prose paragraphs; the list and blockquote paragraphs must NOT count.
if got := len(overview.Paragraphs); got != 2 {
t.Errorf("len(Paragraphs) = %d, want 2", got)
}
}

func TestParagraphsEmptySection(t *testing.T) {
src := []byte("# Title\n\n## Empty\n\n## Next\n\ntext\n")
p := New()
doc, err := p.Parse("test.md", src)
if err != nil {
t.Fatalf("Parse() error: %v", err)
}
for _, s := range doc.GetSections() {
if s.Heading != nil && s.Heading.Text == "Empty" {
if got := len(s.Paragraphs); got != 0 {
t.Errorf("Empty section len(Paragraphs) = %d, want 0", got)
}
}
}
}
8 changes: 7 additions & 1 deletion internal/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) {
lists := make([]*List, 0)
tables := make([]*Table, 0)
images := make([]*Image, 0)
paragraphs := make([]*Paragraph, 0)

// Walk the AST and extract elements
if err := ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
Expand Down Expand Up @@ -98,6 +99,11 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) {
case *ast.Image:
image := extractImage(node, content)
images = append(images, image)

case *ast.Paragraph:
if node.Parent() != nil && node.Parent().Kind() == ast.KindDocument {
paragraphs = append(paragraphs, extractParagraph(node, content))
}
}

return ast.WalkContinue, nil
Expand All @@ -106,7 +112,7 @@ func (p *Parser) Parse(path string, content []byte) (*Document, error) {
}

// Build hierarchical structure
root := p.buildHierarchicalSections(headings, codeBlocks, tables, links, images, lists, content)
root := p.buildHierarchicalSections(headings, codeBlocks, tables, links, images, lists, paragraphs, content)

// Create the document
doc := &Document{
Expand Down
10 changes: 10 additions & 0 deletions internal/parser/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type Section struct {
Links []*Link
Images []*Image
Lists []*List
Paragraphs []*Paragraph
}

// collectSections recursively collects all sections in document order
Expand Down Expand Up @@ -76,6 +77,12 @@ type List struct {
Column int
}

// Paragraph represents a top-level prose paragraph (direct child of the document)
type Paragraph struct {
Line int
Column int
}

// Table represents a table in the document
type Table struct {
Headers []string
Expand Down Expand Up @@ -118,3 +125,6 @@ func (t *Table) GetLine() int { return t.Line }

// GetLine returns the line number of the image
func (i *Image) GetLine() int { return i.Line }

// GetLine returns the line number of the paragraph
func (p *Paragraph) GetLine() int { return p.Line }
4 changes: 4 additions & 0 deletions internal/parser/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ func getPosition(node ast.Node, content []byte) (line, col int) {
if n.Lines().Len() > 0 {
return calculateLineColumn(content, n.Lines().At(0).Start)
}
case *ast.Paragraph:
if n.Lines().Len() > 0 {
return calculateLineColumn(content, n.Lines().At(0).Start)
}
}

// Fallback to finding the first text node
Expand Down
71 changes: 71 additions & 0 deletions internal/rules/paragraph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package rules

import (
"fmt"
"strings"

"github.com/jackchuka/mdschema/internal/schema"
"github.com/jackchuka/mdschema/internal/vast"
)

// ParagraphRule validates paragraph-count requirements for sections
type ParagraphRule struct {
}

var _ StructuralRule = (*ParagraphRule)(nil)

// NewParagraphRule creates a new paragraph rule
func NewParagraphRule() *ParagraphRule {
return &ParagraphRule{}
}

// Name returns the rule identifier
func (r *ParagraphRule) Name() string {
return "paragraph"
}

// ValidateWithContext validates using VAST (validation-ready AST)
func (r *ParagraphRule) ValidateWithContext(ctx *vast.Context) []Violation {
violations := make([]Violation, 0)

ctx.Tree.WalkBound(func(n *vast.Node) bool {
if n.Element.SectionRules != nil && n.Element.Paragraphs != nil {
rule := n.Element.Paragraphs
count := len(n.Paragraphs())
line, col := n.Location()

if rule.Min > 0 && count < rule.Min {
violations = append(violations,
NewViolation(r.Name(), fmt.Sprintf("Section '%s' has too few paragraphs (minimum %d, found %d)", n.HeadingText(), rule.Min, count), line, col))
}

if rule.Max > 0 && count > rule.Max {
violations = append(violations,
NewViolation(r.Name(), fmt.Sprintf("Section '%s' has too many paragraphs (maximum %d, found %d)", n.HeadingText(), rule.Max, count), line, col))
}
}
return true
})

return violations
}

// GenerateContent generates placeholder content for paragraph rules
func (r *ParagraphRule) GenerateContent(builder *strings.Builder, element schema.StructureElement) bool {
if element.SectionRules == nil || element.Paragraphs == nil {
return false
}

rule := element.Paragraphs

builder.WriteString("<!-- Paragraph requirements: -->\n")
if rule.Min > 0 {
fmt.Fprintf(builder, "<!-- Minimum %d paragraphs required -->\n", rule.Min)
}
if rule.Max > 0 {
fmt.Fprintf(builder, "<!-- Maximum %d paragraphs allowed -->\n", rule.Max)
}
builder.WriteString("\n")

return true
}
Loading