diff --git a/v2/Cargo.lock b/v2/Cargo.lock
index 5185387..f8bcf66 100644
--- a/v2/Cargo.lock
+++ b/v2/Cargo.lock
@@ -1238,15 +1238,6 @@ dependencies = [
  "version_check",
 ]
 
-[[package]]
-name = "getopts"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
-dependencies = [
- "unicode-width",
-]
-
 [[package]]
 name = "getrandom"
 version = "0.1.16"
@@ -1730,6 +1721,12 @@ version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
+[[package]]
+name = "lexopt"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401"
+
 [[package]]
 name = "libc"
 version = "0.2.150"
@@ -2880,8 +2877,8 @@ dependencies = [
  "dirs",
  "emojis",
  "env_logger",
- "getopts",
  "insta",
+ "lexopt",
  "log",
  "memchr",
  "muda",
@@ -3341,12 +3338,6 @@ version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
 
-[[package]]
-name = "unicode-width"
-version = "0.1.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
-
 [[package]]
 name = "unsafe-libyaml"
 version = "0.2.9"
diff --git a/v2/Cargo.toml b/v2/Cargo.toml
index 48f2c7b..109dc76 100644
--- a/v2/Cargo.toml
+++ b/v2/Cargo.toml
@@ -32,7 +32,7 @@ anyhow = "1.0.75"
 dirs = "5.0.1"
 emojis = "0.6.1"
 env_logger = "0.10.0"
-getopts = "0.2.21"
+lexopt = "0.3.0"
 log = "0.4.20"
 memchr = "2.6.4"
 muda = "0.10.0"
diff --git a/v2/src/cli.rs b/v2/src/cli.rs
index 0c706f5..16b2d34 100644
--- a/v2/src/cli.rs
+++ b/v2/src/cli.rs
@@ -1,7 +1,6 @@
 use anyhow::Result;
-use getopts::Options as GetOpts;
 use std::env;
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum ThemeOption {
@@ -10,8 +9,27 @@ pub enum ThemeOption {
     Light,
 }
 
+impl ThemeOption {
+    fn new(name: &str) -> Result<Self> {
+        match name {
+            "dark" | "Dark" => Ok(Self::Dark),
+            "light" | "Light" => Ok(Self::Light),
+            "system" | "System" => Ok(Self::System),
+            _ => anyhow::bail!(
+                r#"Value for --theme must be one of "dark", "light" or "system" but got {name:?}"#,
+            ),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum Parsed {
+    Options(Options),
+    Help(&'static str),
+}
+
 #[non_exhaustive]
-#[derive(Debug, Default, PartialEq)]
+#[derive(Debug, PartialEq)]
 pub struct Options {
     pub debug: bool,
     pub init_file: Option<PathBuf>,
@@ -23,84 +41,91 @@ pub struct Options {
     pub data_dir: Option<PathBuf>,
 }
 
-impl Options {
-    pub fn from_args(iter: impl Iterator<Item = String>) -> Result<Option<Self>> {
-        let mut opts = GetOpts::new();
-        opts.optflag("h", "help", "print this help");
-        opts.optopt("t", "theme", r#"window theme ("dark", "light" or "system")"#, "THEME");
-        opts.optflag("", "no-watch", "disable to watch file changes");
-        opts.optflag(
-            "",
-            "generate-config-file",
-            "generate default config file at the config directory. this overwrites an existing file",
-        );
-        opts.optopt("", "config-dir", "custom config directory path", "PATH");
-        opts.optopt("", "data-dir", "custom data directory path", "PATH");
-        opts.optflag("", "debug", "enable debug features");
-
-        let matches = opts.parse(iter)?;
-
-        #[allow(clippy::print_stdout)]
-        if matches.opt_present("h") {
-            println!("{}", opts.usage("Usage: shiba [option] [PATH...]"));
-            return Ok(None);
+impl Default for Options {
+    fn default() -> Self {
+        Self {
+            debug: false,
+            init_file: None,
+            watch_paths: vec![],
+            watch: true,
+            theme: None,
+            gen_config_file: false,
+            config_dir: None,
+            data_dir: None,
         }
+    }
+}
 
-        let theme = match matches.opt_str("t") {
-            Some(theme) => match theme.as_str() {
-                "dark" | "Dark" => Some(ThemeOption::Dark),
-                "light" | "Light" => Some(ThemeOption::Light),
-                "system" | "System" => Some(ThemeOption::System),
-                _ => anyhow::bail!(
-                    r#"Value for --theme must be one of "dark", "light" or "system" but got {:?}"#,
-                    theme,
-                ),
-            },
-            None => None,
-        };
-        let watch = !matches.opt_present("no-watch");
-        let gen_config_file = matches.opt_present("generate-config-file");
-        let config_dir = matches.opt_str("config-dir").map(PathBuf::from);
-        let data_dir = matches.opt_str("data-dir").map(PathBuf::from);
-        let debug = matches.opt_present("debug");
-
-        let mut init_file = None;
-        let mut watch_paths = vec![];
-        let mut cwd: Option<PathBuf> = None;
-        for arg in matches.free.iter() {
-            let path = Path::new(arg);
-            let exists = path.exists();
-
-            // `path.canonicalize()` returns an error when the path does not exist. Instead, create the absolute path
-            // using current directory as a parent
-            let path = if exists {
-                path.canonicalize()?
-            } else if let Some(dir) = &cwd {
-                dir.join(path)
-            } else {
-                let dir = env::current_dir()?.canonicalize()?;
-                let path = dir.join(path);
-                cwd = Some(dir);
-                path
+impl Options {
+    const USAGE: &'static str = r#"
+    Usage: shiba [options...] [PATH...]
+
+    Options:
+        -t, --theme THEME           Window theme ("dark", "light" or "system")
+            --no-watch              Disable to watch file changes
+            --generate-config-file  Generate the default config file overwriting an existing file
+            --config-dir PATH       Custom the config directory path
+            --data-dir PATH         Custom the data directory path
+            --debug                 Enable debug features
+        -h, --help                  Print this help
+    "#;
+
+    pub fn parse(args: impl Iterator<Item = String>) -> Result<Parsed> {
+        use lexopt::prelude::*;
+
+        fn value(parser: &mut lexopt::Parser) -> Result<String> {
+            let Ok(v) = parser.value()?.into_string() else {
+                anyhow::bail!("Invalid UTF-8 sequence in command line argument");
             };
+            if v.starts_with('-') {
+                anyhow::bail!("Expected option value but got option name {v}");
+            }
+            Ok(v)
+        }
+
+        let mut opts = Options::default();
 
-            if init_file.is_some() || path.is_dir() || !exists {
-                watch_paths.push(path);
-            } else {
-                init_file = Some(path);
+        let mut cwd: Option<PathBuf> = None;
+        let mut parser = lexopt::Parser::from_iter(args);
+        while let Some(arg) = parser.next()? {
+            match arg {
+                Short('h') | Long("help") => return Ok(Parsed::Help(Self::USAGE)),
+                Short('t') | Long("theme") => {
+                    opts.theme = Some(ThemeOption::new(&value(&mut parser)?)?);
+                }
+                Long("no-watch") => opts.watch = false,
+                Long("generate-config-file") => opts.gen_config_file = true,
+                Long("config-dir") => opts.config_dir = Some(value(&mut parser)?.into()),
+                Long("data-dir") => opts.data_dir = Some(value(&mut parser)?.into()),
+                Long("debug") => opts.debug = true,
+                Value(path) => {
+                    let path = PathBuf::from(path);
+                    let exists = path.exists();
+
+                    // `path.canonicalize()` returns an error when the path does not exist. Instead, create the absolute path
+                    // using current directory as a parent
+                    let path = if exists {
+                        path.canonicalize()?
+                    } else if let Some(dir) = &cwd {
+                        dir.join(path)
+                    } else {
+                        let dir = env::current_dir()?.canonicalize()?;
+                        let path = dir.join(path);
+                        cwd = Some(dir);
+                        path
+                    };
+
+                    if opts.init_file.is_some() || path.is_dir() || !exists {
+                        opts.watch_paths.push(path);
+                    } else {
+                        opts.init_file = Some(path);
+                    }
+                }
+                _ => return Err(arg.unexpected().into()),
             }
         }
 
-        Ok(Some(Self {
-            debug,
-            init_file,
-            watch_paths,
-            watch,
-            theme,
-            gen_config_file,
-            config_dir,
-            data_dir,
-        }))
+        Ok(Parsed::Options(opts))
     }
 }
 
@@ -108,15 +133,24 @@ impl Options {
 mod tests {
     use super::*;
 
+    fn cmdline(args: &[&str]) -> impl Iterator<Item = String> {
+        let mut c = vec!["shiba".to_string()];
+        c.extend(args.iter().map(ToString::to_string));
+        c.into_iter()
+    }
+
     #[test]
     fn parse_args_ok() {
         let cur = env::current_dir().unwrap().canonicalize().unwrap();
-        let tests = &[
-            (&[][..], Options { watch: true, ..Default::default() }),
+        #[rustfmt::skip]
+        let tests = [
+            (
+                &[][..],
+                Options::default(),
+            ),
             (
                 &["README.md"][..],
                 Options {
-                    watch: true,
                     init_file: Some(cur.join("README.md")),
                     ..Default::default()
                 },
@@ -124,7 +158,6 @@ mod tests {
             (
                 &["README.md", "src"][..],
                 Options {
-                    watch: true,
                     init_file: Some(cur.join("README.md")),
                     watch_paths: vec![cur.join("src")],
                     ..Default::default()
@@ -133,22 +166,26 @@ mod tests {
             (
                 &["file-not-existing.md"][..],
                 Options {
-                    watch: true,
                     init_file: None,
                     watch_paths: vec![cur.join("file-not-existing.md")],
                     ..Default::default()
                 },
             ),
-            (&["--no-watch"][..], Options::default()),
-            (&["--debug"][..], Options { watch: true, debug: true, ..Default::default() }),
+            (
+                &["--no-watch"][..],
+                Options { watch: false, ..Default::default() },
+            ),
+            (
+                &["--debug"][..],
+                Options { debug: true, ..Default::default() },
+            ),
             (
                 &["--theme", "dark"][..],
-                Options { watch: true, theme: Some(ThemeOption::Dark), ..Default::default() },
+                Options { theme: Some(ThemeOption::Dark), ..Default::default() },
             ),
             (
                 &["--config-dir", "some-dir"][..],
                 Options {
-                    watch: true,
                     config_dir: Some(PathBuf::from("some-dir")),
                     ..Default::default()
                 },
@@ -156,7 +193,6 @@ mod tests {
             (
                 &["--data-dir", "some-dir"][..],
                 Options {
-                    watch: true,
                     data_dir: Some(PathBuf::from("some-dir")),
                     ..Default::default()
                 },
@@ -164,31 +200,54 @@ mod tests {
         ];
 
         for (args, want) in tests {
-            let opts = Options::from_args(args.iter().map(|&s| String::from(s))).unwrap().unwrap();
-            assert_eq!(&opts, want, "args={:?}", args);
+            match Options::parse(cmdline(args)).unwrap() {
+                Parsed::Options(opts) => assert_eq!(opts, want, "args={args:?}"),
+                Parsed::Help(_) => panic!("--help is returned for {args:?}"),
+            }
         }
     }
 
     #[test]
     fn help_option() {
-        let args = [String::from("--help")];
-        let opts = Options::from_args(args.into_iter()).unwrap();
-        assert!(opts.is_none(), "{:?}", opts);
+        match Options::parse(cmdline(&["--help"])).unwrap() {
+            Parsed::Options(opts) => panic!("--help is not recognized: {opts:?}"),
+            Parsed::Help(help) => {
+                assert!(help.contains("Usage: shiba [options...] [PATH...]"), "{help:?}")
+            }
+        }
     }
 
     #[test]
     fn parse_args_error() {
-        let args = [String::from("--foo")];
-        let err = Options::from_args(args.into_iter()).unwrap_err();
-        assert!(format!("{}", err).contains("Unrecognized option"), "{:?}", err);
-
-        let args = [String::from("--theme"), String::from("foo")];
-        let err = Options::from_args(args.into_iter()).unwrap_err();
-        let msg = format!("{}", err);
-        assert!(
-            msg.contains(r#"Value for --theme must be one of "dark", "light" or "system""#),
-            "{:?}",
-            err,
-        );
+        let err = Options::parse(cmdline(&["--foo"])).unwrap_err();
+        let msg = format!("{err}");
+        assert!(msg.contains("invalid option '--foo'"), "unexpected message {msg:?}");
+
+        // Test missing value
+        for arg in ["--config-dir", "--data-dir", "--theme"] {
+            let err = Options::parse(cmdline(&[arg, "--debug"])).unwrap_err();
+            assert_eq!(
+                format!("{err}"),
+                "Expected option value but got option name --debug",
+                "unexpected message {err:?} for {arg:?}",
+            );
+
+            let err = Options::parse(cmdline(&["--debug", arg])).unwrap_err();
+            assert_eq!(
+                format!("{err}"),
+                format!("missing argument for option '{arg}'"),
+                "unexpected message {err:?} for {arg:?}",
+            );
+        }
+
+        // Test --theme
+        {
+            let err = Options::parse(cmdline(&["--theme", "foo"])).unwrap_err();
+            let msg = format!("{err}");
+            assert!(
+                msg.contains(r#"Value for --theme must be one of "dark", "light" or "system""#),
+                "unexpected message {msg:?}",
+            );
+        }
     }
 }
diff --git a/v2/src/lib.rs b/v2/src/lib.rs
index e00539b..771ed45 100644
--- a/v2/src/lib.rs
+++ b/v2/src/lib.rs
@@ -19,7 +19,7 @@ mod watcher;
 mod windows;
 mod wry;
 
-pub use cli::Options;
+pub use cli::{Options, Parsed};
 #[cfg(feature = "__bench")]
 pub use markdown::{MarkdownContent, MarkdownParser};
 #[cfg(feature = "__bench")]
diff --git a/v2/src/main.rs b/v2/src/main.rs
index 69960c4..9f7ae24 100644
--- a/v2/src/main.rs
+++ b/v2/src/main.rs
@@ -5,7 +5,7 @@
 
 use anyhow::Result;
 use log::LevelFilter;
-use shiba_preview::{run, Options};
+use shiba_preview::{run, Options, Parsed};
 use std::env;
 
 fn main() -> Result<()> {
@@ -14,12 +14,19 @@ fn main() -> Result<()> {
     #[cfg(all(windows, not(debug_assertions), not(__bench)))]
     let _console = shiba_preview::WindowsConsole::attach();
 
-    let Some(options) = Options::from_args(env::args().skip(1))? else { return Ok(()) };
-    let level = if options.debug { LevelFilter::Debug } else { LevelFilter::Info };
-    env_logger::builder()
-        .filter_level(level)
-        .format_timestamp(None)
-        .filter_module("html5ever", LevelFilter::Off)
-        .init();
-    run(options)
+    match Options::parse(env::args())? {
+        Parsed::Options(options) => {
+            let level = if options.debug { LevelFilter::Debug } else { LevelFilter::Info };
+            env_logger::builder()
+                .filter_level(level)
+                .format_timestamp(None)
+                .filter_module("html5ever", LevelFilter::Off)
+                .init();
+            run(options)
+        }
+        Parsed::Help(help) => {
+            println!("{}", help);
+            Ok(())
+        }
+    }
 }