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
22 changes: 16 additions & 6 deletions cmd/hype/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,17 +181,27 @@ func New(root string, info VersionInfo) *App {
Info: info,
}

val := &Validate{
Cmd: cleo.Cmd{
Name: "validate",
Aliases: []string{"val"},
Desc: "validate document integrity without full export",
},
Parser: p,
}

app := &App{
Cmd: cleo.Cmd{
Name: "hype",
FS: cab,
Commands: map[string]cleo.Commander{
"marked": m,
"preview": pv,
"slides": sl,
"export": e,
"blog": bl,
"version": ver,
"marked": m,
"preview": pv,
"slides": sl,
"export": e,
"blog": bl,
"version": ver,
"validate": val,
},
},
Parser: p,
Expand Down
7 changes: 7 additions & 0 deletions cmd/hype/cli/testdata/validate/errors/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Document With Errors

<img src="nonexistent.png" />

### Heading Skip

This skips h2.
7 changes: 7 additions & 0 deletions cmd/hype/cli/testdata/validate/valid/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Valid Document

This is valid.

## Section One

Content here.
205 changes: 205 additions & 0 deletions cmd/hype/cli/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package cli

import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"

"github.com/gopherguides/hype"
"github.com/markbates/cleo"
)

type Validate struct {
cleo.Cmd

File string
Timeout time.Duration
Parser *hype.Parser
Verbose bool
Exec bool
Format string

flags *flag.FlagSet
mu sync.RWMutex
}

func (cmd *Validate) SetParser(p *hype.Parser) error {
if cmd == nil {
return fmt.Errorf("validate is nil")
}

cmd.mu.Lock()
defer cmd.mu.Unlock()

cmd.Parser = p
return nil
}

func (cmd *Validate) Flags(stderr io.Writer) (*flag.FlagSet, error) {
usage := `
Usage: hype validate [options]

Examples:
hype validate -f document.md
hype validate -f document.md --exec
hype validate -f document.md -v
hype validate -f document.md --format=json
`

if err := cmd.validate(); err != nil {
return nil, err
}

cmd.mu.Lock()
defer cmd.mu.Unlock()

if cmd.flags != nil {
return cmd.flags, nil
}

cmd.flags = flag.NewFlagSet("validate", flag.ContinueOnError)
cmd.flags.SetOutput(stderr)
cmd.flags.StringVar(&cmd.File, "f", "hype.md", "file to validate")
cmd.flags.DurationVar(&cmd.Timeout, "timeout", DefaultTimeout, "timeout for execution, defaults to 30 seconds (30s)")
cmd.flags.BoolVar(&cmd.Verbose, "v", false, "enable verbose output")
cmd.flags.BoolVar(&cmd.Exec, "exec", false, "also validate code execution")
cmd.flags.StringVar(&cmd.Format, "format", "text", "output format: text, json")

cmd.flags.Usage = func() {
fmt.Fprintf(stderr, "Usage of %s:\n", os.Args[0])
cmd.flags.PrintDefaults()
fmt.Fprintln(stderr, usage)
}

return cmd.flags, nil
}

func (cmd *Validate) Main(ctx context.Context, pwd string, args []string) error {
cmd.mu.Lock()
to := cmd.Timeout
if to == 0 {
to = DefaultTimeout
cmd.Timeout = to
}
cmd.mu.Unlock()

return cmd.main(ctx, pwd, args)
}

func (cmd *Validate) main(ctx context.Context, pwd string, args []string) error {
if err := cmd.validate(); err != nil {
return err
}

if err := (&cmd.Cmd).Init(); err != nil {
return err
}

flags, err := cmd.Flags(cmd.Stderr())
if err != nil {
return err
}

if err := flags.Parse(args); err != nil {
return err
}

if cmd.Verbose {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})))
}

return WithTimeout(ctx, cmd.Timeout, func(ctx context.Context) error {
return WithinDir(pwd, func() error {
return cmd.execute(ctx, pwd)
})
})
}

func (cmd *Validate) execute(ctx context.Context, pwd string) error {
if err := cmd.validate(); err != nil {
return err
}

if cmd.FS == nil {
cmd.FS = os.DirFS(pwd)
}

slog.Debug("validate", "pwd", pwd, "file", cmd.File)
fileDir := filepath.Dir(cmd.File)
fileName := filepath.Base(cmd.File)

parserFS := cmd.FS
if fileDir != "." && fileDir != "" {
subFS, err := fs.Sub(cmd.FS, fileDir)
if err != nil {
return fmt.Errorf("failed to create sub filesystem for %s: %w", fileDir, err)
}
parserFS = subFS
}

p := cmd.Parser
if p == nil {
p = hype.NewParser(parserFS)
} else {
p.FS = parserFS
}

p.Root = filepath.Join(pwd, fileDir)

doc, err := p.ParseFile(fileName)
if err != nil {
return fmt.Errorf("parse error: %w", err)
}

result := hype.Validate(ctx, doc, hype.ValidateOptions{Exec: cmd.Exec})

out := cmd.Stdout()

switch cmd.Format {
case "json":
b, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Fprintln(out, string(b))
case "text":
for _, issue := range result.Issues {
fmt.Fprintln(out, issue.String())
}
if len(result.Issues) > 0 {
fmt.Fprintln(out)
}
fmt.Fprintln(out, result.Summary())
default:
return fmt.Errorf("unsupported format: %s", cmd.Format)
}

if result.HasErrors() {
return fmt.Errorf("validation failed: %s", result.Summary())
}

return nil
}

func (cmd *Validate) validate() error {
if cmd == nil {
return fmt.Errorf("cmd is nil")
}

cmd.mu.Lock()
defer cmd.mu.Unlock()

if cmd.Timeout == 0 {
cmd.Timeout = DefaultTimeout
}

return nil
}
54 changes: 54 additions & 0 deletions cmd/hype/cli/validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cli

import (
"context"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func Test_Validate_Main_Clean(t *testing.T) {
r := require.New(t)

pwd, err := filepath.Abs("testdata/validate/valid")
r.NoError(err)

cmd := &Validate{}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err = cmd.Main(ctx, pwd, []string{"-f", "module.md"})
r.NoError(err)
}

func Test_Validate_Main_WithErrors(t *testing.T) {
r := require.New(t)

pwd, err := filepath.Abs("testdata/validate/errors")
r.NoError(err)

cmd := &Validate{}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err = cmd.Main(ctx, pwd, []string{"-f", "module.md"})
r.Error(err)
r.Contains(err.Error(), "validation failed")
}

func Test_Validate_Main_JSONFormat(t *testing.T) {
r := require.New(t)

pwd, err := filepath.Abs("testdata/validate/errors")
r.NoError(err)

cmd := &Validate{}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err = cmd.Main(ctx, pwd, []string{"-f", "module.md", "-format", "json"})
r.Error(err)
r.Contains(err.Error(), "validation failed")
}
9 changes: 9 additions & 0 deletions testdata/validate/broken-anchor/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Broken Anchor

See <a href="#nonexistent">this reference</a> for details.

<figure id="existing-fig">

<figcaption>A figure</figcaption>

</figure>
9 changes: 9 additions & 0 deletions testdata/validate/broken-ref/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Broken Ref

See <ref id="missing-fig"></ref> for details.

<figure id="existing-fig">

<figcaption>A figure</figcaption>

</figure>
13 changes: 13 additions & 0 deletions testdata/validate/duplicate-id/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Duplicate IDs

<figure id="dup">

<figcaption>First figure</figcaption>

</figure>

<figure id="dup">

<figcaption>Second figure</figcaption>

</figure>
7 changes: 7 additions & 0 deletions testdata/validate/heading-skip/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Heading Level One

Some content.

### Heading Level Three

This skips heading level two.
3 changes: 3 additions & 0 deletions testdata/validate/missing-image/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Missing Image

<img src="images/nonexistent.png" />
3 changes: 3 additions & 0 deletions testdata/validate/missing-source/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Missing Source

<code src="missing.go"></code>
11 changes: 11 additions & 0 deletions testdata/validate/valid/module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Valid Document

This is a valid document with proper structure.

## Section One

Some content here.

### Subsection

More content.
7 changes: 7 additions & 0 deletions testdata/validate/valid/src/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package main

import "fmt"

func main() {
fmt.Println("Hello, World!")
}
Loading
Loading