summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShulhan <ms@kilabit.info>2024-12-16 01:01:24 +0700
committerShulhan <ms@kilabit.info>2024-12-28 16:26:40 +0700
commitc14c0e7735778573dc4d87e39703f0524d08a1b0 (patch)
tree6e4e5c30584dbbc986063dcc754162ec778147d5
parent5f97e1d495dd341f2bf573d80b0edb4d02a78a7b (diff)
downloadpakakeh.go-c14c0e7735778573dc4d87e39703f0524d08a1b0.tar.xz
watchfs/v2: implement new directory watcher
DirWatcher scan the content of directory in [fs.DirWatcherOptions.Root] recursively for the files to be watched, using the [fs.DirWatcherOptions.Includes] field. A single file, [fs.DirWatcherOptions.FileWatcherOptions.FilePath], will be watched for changes that will trigger re-scanning the content of Root recursively. The result of re-scanning is list of the Includes files (only files not new directory) that are changes, which will be send to channel C. On each [os.FileInfo] received from C, a deleted file have [os.FileInfo.Size] equal to [NodeFlagDeleted]. The channel will send an empty slice if no changes. The implementation of file changes in this code is naive, using loop and comparison of mode, modification time, and size; at least it should works on most operating system.
-rw-r--r--.gitignore2
-rw-r--r--lib/watchfs/v2/dir_watcher.go306
-rw-r--r--lib/watchfs/v2/dir_watcher_example_test.go82
-rw-r--r--lib/watchfs/v2/dir_watcher_options.go86
-rw-r--r--lib/watchfs/v2/dir_watcher_test.go296
-rw-r--r--lib/watchfs/v2/node.go92
-rw-r--r--lib/watchfs/v2/testdata/exc/index.adoc0
-rw-r--r--lib/watchfs/v2/testdata/inc/index.adoc1
-rw-r--r--lib/watchfs/v2/testdata/inc/index.css0
-rw-r--r--lib/watchfs/v2/testdata/inc/index.html0
-rw-r--r--lib/watchfs/v2/watchfs.go2
11 files changed, 865 insertions, 2 deletions
diff --git a/.gitignore b/.gitignore
index 14402872..5f8baec8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,6 @@
*.cprof
*.csv
*.dat
-*.html
*.mprof
*.oob
*.perf
@@ -15,6 +14,7 @@
*.stats
*.test
*.zst
+/*.html
/_bin/ansua
/_bin/bcrypt
/_bin/emaildecode
diff --git a/lib/watchfs/v2/dir_watcher.go b/lib/watchfs/v2/dir_watcher.go
new file mode 100644
index 00000000..0cbef3b1
--- /dev/null
+++ b/lib/watchfs/v2/dir_watcher.go
@@ -0,0 +1,306 @@
+// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
+// SPDX-License-Identifier: BSD-3-Clause
+
+package watchfs
+
+import (
+ "errors"
+ "fmt"
+ "maps"
+ "os"
+ "path/filepath"
+ "slices"
+ "time"
+)
+
+// DirWatcher scan the content of directory in
+// [watchfs.DirWatcherOptions.Root] recursively for the files to be watched,
+// using the [watchfs.DirWatcherOptions.Includes] field.
+// A single file, [watchfs.DirWatcherOptions.File], will be watched for
+// changes that will trigger re-scanning the content of Root recursively.
+//
+// The result of re-scanning is list of the Includes files (only files not
+// new directory) that are changes, which will be send to channel C.
+// On each [os.FileInfo] received from C, a deleted file have
+// [os.FileInfo.Size] equal to [FileFlagDeleted].
+// The channel will send an empty slice if no changes.
+//
+// The implementation of file changes in this code is naive, using loop and
+// comparison of mode, modification time, and size; at least it should works
+// on most operating system.
+type DirWatcher struct {
+ // idxDir contains index of directory.
+ // It is used to detect new or deleted file inside that directory.
+ idxDir map[string]node
+
+ // idxFile contains index of files.
+ idxFile map[string]node
+
+ idxNewFile map[string]node
+
+ // C received the new, updated, and deleted files.
+ C <-chan []os.FileInfo
+ c chan []os.FileInfo
+
+ fwatch *FileWatcher
+ opts DirWatcherOptions
+}
+
+// WatchDir create and start scanning directory for changes.
+func WatchDir(opts DirWatcherOptions) (dwatch *DirWatcher, err error) {
+ err = opts.init()
+ if err != nil {
+ return nil, fmt.Errorf(`WatchDir: %w`, err)
+ }
+
+ dwatch = &DirWatcher{
+ c: make(chan []os.FileInfo, 1),
+ opts: opts,
+ idxDir: map[string]node{},
+ idxFile: map[string]node{},
+ idxNewFile: map[string]node{},
+ }
+ dwatch.C = dwatch.c
+ dwatch.initialScan(dwatch.opts.Root)
+ dwatch.fwatch = WatchFile(dwatch.opts.FileWatcherOptions)
+ go dwatch.watch()
+ return dwatch, nil
+}
+
+// Files return all the files currently being watched, the one that filtered
+// by [watchfs.DirWatcherOptions.Includes], with its file information.
+// This method is not safe when called when DirWatcher has been running.
+func (dwatch *DirWatcher) Files() (files map[string]os.FileInfo) {
+ files = make(map[string]os.FileInfo)
+ for key, node := range dwatch.idxFile {
+ if node.size == nodeFlagExcluded {
+ continue
+ }
+ files[key] = &node
+ }
+ return files
+}
+
+// ForceRescan force to rescan for changes without waiting for
+// [watchfs.DirWatcherOptions.File] to be updated.
+func (dwatch *DirWatcher) ForceRescan() {
+ if dwatch.fwatch != nil {
+ dwatch.fwatch.c <- &node{
+ name: `.watchfs_v2_forced`,
+ size: nodeFlagForced,
+ mtime: time.Now(),
+ }
+ }
+}
+
+// Stop watching the file and re-scanning the Root directory.
+func (dwatch *DirWatcher) Stop() {
+ if dwatch.fwatch != nil {
+ dwatch.fwatch.Stop()
+ }
+}
+
+func (dwatch *DirWatcher) indexingFile(apath string) (anode *node) {
+ if dwatch.opts.isExcluded(apath) {
+ return &nodeExcluded
+ }
+ if !dwatch.opts.isIncluded(apath) {
+ return &nodeExcluded
+ }
+ if apath == dwatch.opts.FileWatcherOptions.File {
+ return &nodeExcluded
+ }
+ anode, _ = newNode(apath)
+ // The newNode may return nil, so we will
+ // let the next re-scan to include them
+ // later.
+ return anode
+}
+
+// initialScan scan the directory dir and its sub-directories to get the
+// list of directory and the list of included files.
+func (dwatch *DirWatcher) initialScan(dir string) (changes []os.FileInfo) {
+ var (
+ dirq = []string{dir}
+ name string
+ apath string
+ anode *node
+ listde []os.DirEntry
+ err error
+ de os.DirEntry
+ )
+ for len(dirq) > 0 {
+ dir = dirq[0]
+ dirq = dirq[1:]
+
+ anode, err = newNode(dir)
+ if err != nil {
+ continue
+ }
+ if dwatch.opts.isExcluded(dir) {
+ dwatch.idxDir[dir] = nodeExcluded
+ continue
+ }
+
+ listde, err = os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+ anode.size = int64(len(listde))
+ dwatch.idxDir[dir] = *anode
+
+ for _, de = range listde {
+ name = de.Name()
+ apath = filepath.Join(dir, name)
+ if de.IsDir() {
+ dirq = append(dirq, apath)
+ continue
+ }
+ anode = dwatch.indexingFile(apath)
+ if anode != nil {
+ dwatch.idxFile[apath] = *anode
+ changes = append(changes, anode)
+ }
+ }
+ }
+ return changes
+}
+
+func (dwatch *DirWatcher) scanDir() (changes []os.FileInfo) {
+ // Fetch the current keys first, so in case there is new directory
+ // it does not included twice.
+ var keys = slices.Sorted(maps.Keys(dwatch.idxDir))
+ var (
+ dir string
+ anode node
+ newnode *node
+ err error
+ listde []os.DirEntry
+ )
+ for _, dir = range keys {
+ anode = dwatch.idxDir[dir]
+ if anode.size == nodeFlagExcluded {
+ continue
+ }
+
+ newnode, err = newNode(dir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ // The directory has been deleted.
+ anode = node{
+ name: dir,
+ size: FileFlagDeleted,
+ mode: os.ModeDir,
+ }
+ changes = append(changes, &anode)
+ delete(dwatch.idxDir, dir)
+ }
+ continue
+ }
+
+ listde, err = os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+ newnode.size = int64(len(listde))
+
+ if anode.equal(newnode) {
+ continue
+ }
+ dwatch.idxDir[dir] = *newnode
+
+ var (
+ de os.DirEntry
+ name string
+ apath string
+ )
+ for _, de = range listde {
+ name = de.Name()
+ apath = filepath.Join(dir, name)
+
+ if de.IsDir() {
+ anode = dwatch.idxDir[apath]
+ if anode.mtime.IsZero() {
+ // New directory created.
+ var newFiles = dwatch.initialScan(apath)
+ changes = append(changes, newFiles...)
+ }
+ // anode is a directory that has been
+ // indexed.
+ continue
+ }
+
+ anode = dwatch.idxFile[apath]
+ if anode.mtime.IsZero() {
+ // New file created.
+ newnode = dwatch.indexingFile(apath)
+ if newnode != nil {
+ dwatch.idxNewFile[apath] = *newnode
+ }
+ continue
+ }
+ }
+ }
+ return changes
+}
+
+func (dwatch *DirWatcher) scanFile() (fileChanges []os.FileInfo) {
+ var (
+ newnode *node
+ err error
+ )
+ for apath, anode := range dwatch.idxFile {
+ if anode.size == nodeFlagExcluded {
+ continue
+ }
+ newnode, err = newNode(apath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ // File has been deleted.
+ newnode = &node{
+ name: apath,
+ size: FileFlagDeleted,
+ }
+ fileChanges = append(fileChanges, newnode)
+ delete(dwatch.idxFile, apath)
+ }
+ continue
+ }
+ if anode.equal(newnode) {
+ continue
+ }
+ // File has been updated.
+ dwatch.idxFile[apath] = *newnode
+ fileChanges = append(fileChanges, newnode)
+ }
+ return fileChanges
+}
+
+func (dwatch *DirWatcher) watch() {
+ var (
+ dirChanges []os.FileInfo
+ fileChanges []os.FileInfo
+ )
+ for range dwatch.fwatch.C {
+ // Scan new files on each directory.
+ dirChanges = dwatch.scanDir()
+
+ // Scan update or delete files.
+ fileChanges = dwatch.scanFile()
+
+ if len(dwatch.idxNewFile) != 0 {
+ for apath, anode := range dwatch.idxNewFile {
+ dwatch.idxFile[apath] = anode
+ if anode.size == nodeFlagExcluded {
+ continue
+ }
+ fileChanges = append(fileChanges, &anode)
+ }
+ clear(dwatch.idxNewFile)
+ }
+
+ dirChanges = append(dirChanges, fileChanges...)
+ dwatch.c <- dirChanges
+ }
+ close(dwatch.c)
+}
diff --git a/lib/watchfs/v2/dir_watcher_example_test.go b/lib/watchfs/v2/dir_watcher_example_test.go
new file mode 100644
index 00000000..95cce46f
--- /dev/null
+++ b/lib/watchfs/v2/dir_watcher_example_test.go
@@ -0,0 +1,82 @@
+// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
+// SPDX-License-Identifier: BSD-3-Clause
+
+package watchfs_test
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "git.sr.ht/~shulhan/pakakeh.go/lib/watchfs/v2"
+)
+
+func ExampleWatchDir() {
+ var (
+ dirTemp string
+ err error
+ )
+ dirTemp, err = os.MkdirTemp(``, ``)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ var (
+ fileToWatch = filepath.Join(dirTemp, `.rescan`)
+ opts = watchfs.DirWatcherOptions{
+ FileWatcherOptions: watchfs.FileWatcherOptions{
+ File: fileToWatch,
+ Interval: 50 * time.Millisecond,
+ },
+ Root: dirTemp,
+ Includes: []string{`.*\.adoc$`},
+ Excludes: []string{`exc$`, `.*\.html$`},
+ }
+ dwatch *watchfs.DirWatcher
+ )
+
+ dwatch, err = watchfs.WatchDir(opts)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ var (
+ fileAadoc = filepath.Join(opts.Root, `a.adoc`)
+ fileBadoc = filepath.Join(opts.Root, `b.adoc`)
+ fileAhtml = filepath.Join(opts.Root, `a.html`)
+ )
+
+ err = os.WriteFile(fileAadoc, nil, 0600)
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = os.WriteFile(fileAhtml, nil, 0600)
+ if err != nil {
+ log.Fatal(err)
+ }
+ err = os.WriteFile(fileBadoc, nil, 0600)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Write to the file that we watch for changes to trigger rescan.
+ err = os.WriteFile(fileToWatch, []byte(`x`), 0600)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ var changes []os.FileInfo = <-dwatch.C
+ var names []string
+ for _, fi := range changes {
+ // Since we use temporary directory, print only the base
+ // name to make it works on all system.
+ names = append(names, filepath.Base(fi.Name()))
+ }
+ sort.Strings(names)
+ fmt.Println(names)
+ // Output:
+ // [a.adoc b.adoc]
+}
diff --git a/lib/watchfs/v2/dir_watcher_options.go b/lib/watchfs/v2/dir_watcher_options.go
new file mode 100644
index 00000000..6957129c
--- /dev/null
+++ b/lib/watchfs/v2/dir_watcher_options.go
@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
+// SPDX-License-Identifier: BSD-3-Clause
+
+package watchfs
+
+import (
+ "regexp"
+ "strings"
+)
+
+// DirWatcherOptions contains options to watch directory.
+type DirWatcherOptions struct {
+ FileWatcherOptions
+
+ // The root directory where files to be scanned.
+ Root string
+
+ // List of regex for files or directories to be excluded from
+ // scanning.
+ // The Excludes option will be processed before Includes.
+ Excludes []string
+
+ // List of regex for files or directories to be included from
+ // scanning.
+ Includes []string
+
+ reExcludes []*regexp.Regexp
+ reIncludes []*regexp.Regexp
+}
+
+func (opts *DirWatcherOptions) init() (err error) {
+ var (
+ str string
+ re *regexp.Regexp
+ )
+ for _, str = range opts.Excludes {
+ str = strings.TrimSpace(str)
+ if len(str) == 0 {
+ // Accidentally using empty string here may cause
+ // everying get excluded.
+ continue
+ }
+ re, err = regexp.Compile(str)
+ if err != nil {
+ return err
+ }
+ opts.reExcludes = append(opts.reExcludes, re)
+ }
+ for _, str = range opts.Includes {
+ str = strings.TrimSpace(str)
+ if len(str) == 0 {
+ continue
+ }
+ re, err = regexp.Compile(str)
+ if err != nil {
+ return err
+ }
+ opts.reIncludes = append(opts.reIncludes, re)
+ }
+ return nil
+}
+
+func (opts *DirWatcherOptions) isExcluded(pathFile string) bool {
+ var re *regexp.Regexp
+ for _, re = range opts.reExcludes {
+ if re.MatchString(pathFile) {
+ return true
+ }
+ }
+ return false
+}
+
+// isIncluded will return true if the list Includes is empty or it is match
+// with one of the Includes regex.
+func (opts *DirWatcherOptions) isIncluded(pathFile string) bool {
+ if len(opts.reIncludes) == 0 {
+ return true
+ }
+ var re *regexp.Regexp
+ for _, re = range opts.reIncludes {
+ if re.MatchString(pathFile) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/lib/watchfs/v2/dir_watcher_test.go b/lib/watchfs/v2/dir_watcher_test.go
new file mode 100644
index 00000000..2f0552e3
--- /dev/null
+++ b/lib/watchfs/v2/dir_watcher_test.go
@@ -0,0 +1,296 @@
+// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
+// SPDX-License-Identifier: BSD-3-Clause
+
+package watchfs
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+ "time"
+
+ "git.sr.ht/~shulhan/pakakeh.go/lib/test"
+)
+
+func TestWatchDirOptions(t *testing.T) {
+ listCase := []struct {
+ expError string
+ opts DirWatcherOptions
+ }{{
+ opts: DirWatcherOptions{
+ Includes: []string{`.(*\.adoc$`},
+ },
+ expError: "WatchDir: error parsing regexp: missing argument to repetition operator: `*`",
+ }, {
+ opts: DirWatcherOptions{
+ Excludes: []string{`.(*\.adoc$`},
+ },
+ expError: "WatchDir: error parsing regexp: missing argument to repetition operator: `*`",
+ }}
+
+ for _, tc := range listCase {
+ _, err := WatchDir(tc.opts)
+ test.Assert(t, `error`, tc.expError, err.Error())
+ }
+}
+
+func TestWatchDirInitialScan(t *testing.T) {
+ listCase := []struct {
+ expFiles map[string]os.FileInfo
+ desc string
+ opts DirWatcherOptions
+ expIndex DirWatcher
+ }{{
+ desc: `With includes and excludes`,
+ opts: DirWatcherOptions{
+ FileWatcherOptions: FileWatcherOptions{
+ File: `testdata/rescan`,
+ Interval: 50 * time.Millisecond,
+ },
+ Root: `testdata/`,
+ Includes: []string{`.*\.adoc$`},
+ Excludes: []string{`exc$`, `.*\.html$`, ` `},
+ },
+ expIndex: DirWatcher{
+ idxDir: map[string]node{
+ `testdata/`: node{
+ name: `testdata/`,
+ size: 2,
+ mode: fs.ModeDir | 0755,
+ },
+ `testdata/exc`: nodeExcluded,
+ `testdata/inc`: node{
+ name: `testdata/inc`,
+ size: 3,
+ mode: fs.ModeDir | 0755,
+ },
+ },
+ idxFile: map[string]node{
+ `testdata/inc/index.adoc`: node{
+ name: `testdata/inc/index.adoc`,
+ size: 7,
+ mode: 0644,
+ },
+ `testdata/inc/index.css`: nodeExcluded,
+ `testdata/inc/index.html`: nodeExcluded,
+ },
+ },
+ expFiles: map[string]os.FileInfo{
+ `testdata/inc/index.adoc`: &node{
+ name: `testdata/inc/index.adoc`,
+ size: 7,
+ mode: 0644,
+ },
+ },
+ }, {
+ desc: `With empty includes`,
+ opts: DirWatcherOptions{
+ FileWatcherOptions: FileWatcherOptions{
+ File: `testdata/rescan`,
+ Interval: 50 * time.Millisecond,
+ },
+ Root: `testdata/`,
+ Includes: []string{` `},
+ Excludes: []string{`exc$`, `.*\.adoc$`},
+ },
+ expIndex: DirWatcher{
+ idxDir: map[string]node{
+ `testdata/`: node{
+ name: `testdata/`,
+ size: 2,
+ mode: fs.ModeDir | 0755,
+ },
+ `testdata/exc`: nodeExcluded,
+ `testdata/inc`: node{
+ name: `testdata/inc`,
+ size: 3,
+ mode: fs.ModeDir | 0755,
+ },
+ },
+ idxFile: map[string]node{
+ `testdata/inc/index.adoc`: nodeExcluded,
+ `testdata/inc/index.css`: node{
+ name: `testdata/inc/index.css`,
+ mode: 0644,
+ },
+ `testdata/inc/index.html`: node{
+ name: `testdata/inc/index.html`,
+ mode: 0644,
+ },
+ },
+ },
+ expFiles: map[string]os.FileInfo{
+ `testdata/inc/index.css`: &node{
+ name: `testdata/inc/index.css`,
+ mode: 0644,
+ },
+ `testdata/inc/index.html`: &node{
+ name: `testdata/inc/index.html`,
+ mode: 0644,
+ },
+ },
+ }}
+
+ for _, tc := range listCase {
+ dwatch, err := WatchDir(tc.opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dwatch.Stop()
+ test.Assert(t, tc.desc+`: idxDir`, tc.expIndex.idxDir,
+ dwatch.idxDir)
+ test.Assert(t, tc.desc+`: idxFile`, tc.expIndex.idxFile,
+ dwatch.idxFile)
+ gotFiles := dwatch.Files()
+ test.Assert(t, tc.desc+`: Files`, tc.expFiles, gotFiles)
+ }
+}
+
+func TestWatchDir(t *testing.T) {
+ var (
+ dirTemp = t.TempDir()
+ fileToWatch = `rescan`
+ opts = DirWatcherOptions{
+ FileWatcherOptions: FileWatcherOptions{
+ File: filepath.Join(dirTemp, fileToWatch),
+ Interval: 50 * time.Millisecond,
+ },
+ Root: dirTemp,
+ Includes: []string{`.*\.adoc$`},
+ Excludes: []string{`exc$`, `.*\.html$`},
+ }
+ dwatch *DirWatcher
+ err error
+ )
+
+ dwatch, err = WatchDir(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var (
+ fileAadoc = filepath.Join(opts.Root, `a.adoc`)
+ fileBadoc = filepath.Join(opts.Root, `b.adoc`)
+ )
+ t.Run(`On included files created`, func(t *testing.T) {
+ var expNames = []string{
+ fileAadoc,
+ fileBadoc,
+ }
+ for _, file := range expNames {
+ err = os.WriteFile(file, nil, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ dwatch.ForceRescan()
+
+ var gotNames = listFileName(<-dwatch.C)
+ test.Assert(t, `changes`, expNames, gotNames)
+ })
+
+ t.Run(`On dir excluded created`, func(t *testing.T) {
+ err = os.MkdirAll(filepath.Join(opts.Root, `exc`), 0700)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(opts.FileWatcherOptions.File,
+ []byte(`xx`), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotNames []string = listFileName(<-dwatch.C)
+ var expNames []string
+ test.Assert(t, `changes`, expNames, gotNames)
+ })
+
+ t.Run(`On file excluded created`, func(t *testing.T) {
+ var fileAhtml = filepath.Join(opts.Root, `a.html`)
+ err = os.WriteFile(fileAhtml, nil, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(opts.FileWatcherOptions.File,
+ []byte(`xxx`), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotNames []string = listFileName(<-dwatch.C)
+ var expNames []string
+ test.Assert(t, `changes`, expNames, gotNames)
+ })
+
+ t.Run(`On sub directory created`, func(t *testing.T) {
+ var dirInc = filepath.Join(opts.Root, `inc`)
+ err = os.MkdirAll(dirInc, 0700)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fileC = filepath.Join(dirInc, `ccc.adoc`)
+ err = os.WriteFile(fileC, nil, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(opts.FileWatcherOptions.File,
+ []byte(`xxxx`), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotNames []string = listFileName(<-dwatch.C)
+ var expNames = []string{
+ fileC,
+ }
+ test.Assert(t, `changes`, expNames, gotNames)
+ })
+
+ t.Run(`On file updated`, func(t *testing.T) {
+ err = os.WriteFile(fileAadoc, nil, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(opts.FileWatcherOptions.File,
+ []byte(`xxxxx`), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotNames []string = listFileName(<-dwatch.C)
+ var expNames = []string{
+ fileAadoc,
+ }
+ test.Assert(t, `changes`, expNames, gotNames)
+ })
+
+ t.Run(`On file deleted`, func(t *testing.T) {
+ err = os.Remove(fileAadoc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(opts.FileWatcherOptions.File,
+ []byte(`xxxxx x`), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotNames []string = listFileName(<-dwatch.C)
+ var expNames = []string{
+ fileAadoc,
+ }
+ test.Assert(t, `changes`, expNames, gotNames)
+ })
+}
+
+func listFileName(listfi []os.FileInfo) (listName []string) {
+ var fi os.FileInfo
+ for _, fi = range listfi {
+ listName = append(listName, fi.Name())
+ }
+ sort.Strings(listName)
+ return listName
+}
diff --git a/lib/watchfs/v2/node.go b/lib/watchfs/v2/node.go
new file mode 100644
index 00000000..27b51dba
--- /dev/null
+++ b/lib/watchfs/v2/node.go
@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
+// SPDX-License-Identifier: BSD-3-Clause
+
+package watchfs
+
+import (
+ "os"
+ "time"
+)
+
+// FileFlagDeleted indicated that a file has been deleted.
+// The flag is stored inside the [os.FileInfo.Size].
+const FileFlagDeleted = -1
+
+const nodeFlagExcluded = -2
+
+const nodeFlagForced = -3
+
+var nodeExcluded = node{
+ size: nodeFlagExcluded,
+}
+
+type node struct {
+ // The file modification time.
+ // This field also store the flag for excluded and deleted file.
+ mtime time.Time `noequal:""`
+
+ // The name contains the relative path, not only base name.
+ name string
+
+ // Size of file.
+ // For directory the size contains the number of childs, the length of
+ // slice returned by [os.ReadDir].
+ size int64
+
+ mode os.FileMode
+}
+
+func newNode(apath string) (n *node, err error) {
+ var fi os.FileInfo
+ fi, err = os.Stat(apath)
+ if err != nil {
+ return nil, err
+ }
+ n = &node{
+ name: apath,
+ mtime: fi.ModTime(),
+ mode: fi.Mode(),
+ }
+ if !fi.IsDir() {
+ n.size = fi.Size()
+ }
+ return n, nil
+}
+
+func (node *node) IsDir() bool {
+ return node.mode.IsDir()
+}
+
+func (node *node) Mode() os.FileMode {
+ return node.mode
+}
+
+func (node *node) ModTime() time.Time {
+ return node.mtime
+}
+
+// Name return the relative path to the file, not base name of file.
+func (node *node) Name() string {
+ return node.name
+}
+
+func (node *node) Size() int64 {
+ return node.size
+}
+
+func (node *node) Sys() any {
+ return node
+}
+
+func (node *node) equal(other *node) bool {
+ if !node.mtime.Equal(other.mtime) {
+ return false
+ }
+ if node.size != other.size {
+ return false
+ }
+ if node.mode != other.mode {
+ return false
+ }
+ return true
+}
diff --git a/lib/watchfs/v2/testdata/exc/index.adoc b/lib/watchfs/v2/testdata/exc/index.adoc
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/lib/watchfs/v2/testdata/exc/index.adoc
diff --git a/lib/watchfs/v2/testdata/inc/index.adoc b/lib/watchfs/v2/testdata/inc/index.adoc
new file mode 100644
index 00000000..1d6006e8
--- /dev/null
+++ b/lib/watchfs/v2/testdata/inc/index.adoc
@@ -0,0 +1 @@
+= test
diff --git a/lib/watchfs/v2/testdata/inc/index.css b/lib/watchfs/v2/testdata/inc/index.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/lib/watchfs/v2/testdata/inc/index.css
diff --git a/lib/watchfs/v2/testdata/inc/index.html b/lib/watchfs/v2/testdata/inc/index.html
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/lib/watchfs/v2/testdata/inc/index.html
diff --git a/lib/watchfs/v2/watchfs.go b/lib/watchfs/v2/watchfs.go
index f93c1914..0ee2fe78 100644
--- a/lib/watchfs/v2/watchfs.go
+++ b/lib/watchfs/v2/watchfs.go
@@ -1,5 +1,5 @@
// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info>
// SPDX-License-Identifier: BSD-3-Clause
-// Package watchfs implement naive file watcher.
+// Package watchfs implement naive file and directory watcher.
package watchfs