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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ Configure `vimdoc-language-server` in your editor of choice, for example with
vim.lsp.enable('vimdoc_ls')
```

### CLI

The server also provides standalone CLI subcommands that work without an editor.

**Format** vimdoc files (in-place or check-only for CI):

```sh
vimdoc-language-server format doc/
vimdoc-language-server format --check doc/*.txt
vimdoc-language-server --line-width 80 format doc/
```

**Check** for diagnostics (duplicate tags, unresolved taglinks):

```sh
vimdoc-language-server check doc/
vimdoc-language-server check --ignore unresolved-tag doc/
```

## Features

- [x] **Formatting** — separator normalization, prose reflow, heading alignment;
Expand Down
105 changes: 102 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter;

use vimdoc_language_server::{
diagnostics::{self, DiagnosticLevel},
formatter::ReflowMode,
formatter::{self, FormatOptions, ReflowMode},
server::{self, Config, InitOptions},
tags::{self, TagIndex},
};
Expand Down Expand Up @@ -97,6 +97,7 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
Check(CheckArgs),
Format(FormatArgs),
}

#[derive(Args)]
Expand All @@ -106,6 +107,13 @@ struct CheckArgs {
ignore: Vec<String>,
}

#[derive(Args)]
struct FormatArgs {
paths: Vec<PathBuf>,
#[arg(long)]
check: bool,
}

fn server_capabilities(cli: &Cli) -> ServerCapabilities {
ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
Expand Down Expand Up @@ -292,6 +300,95 @@ fn run_check(args: &CheckArgs, cli: &Cli) -> Result<()> {
Ok(())
}

fn collect_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for path in paths {
if path.is_dir() {
let pattern = path.join("**/*.txt");
let pattern_str = pattern
.to_str()
.ok_or_else(|| anyhow::anyhow!("non-UTF-8 path"))?;
for entry in glob::glob(pattern_str)? {
files.push(entry?);
}
} else {
files.push(path.clone());
}
}
files.sort();
files.dedup();
Ok(files)
}

fn run_format(args: &FormatArgs, cli: &Cli) -> Result<()> {
let opts = FormatOptions {
line_width: cli.line_width,
reflow: cli.reflow.into(),
normalize_spacing: cli.normalize_spacing,
};

let files = collect_files(&args.paths)?;
if files.is_empty() {
anyhow::bail!("no files to format");
}

let use_color = resolve_color(cli);
let mut unformatted = 0u32;

for path in &files {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
let formatted = formatter::format_document(&text, &opts);

if text == formatted {
continue;
}

if args.check {
println!(
"{}",
colorize(
&format!("Would reformat: {}", path.display()),
"1;33",
use_color,
)
);
unformatted += 1;
} else {
std::fs::write(path, &formatted)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
println!(
"{}",
colorize(&format!("Formatted: {}", path.display()), "1;32", use_color)
);
}
}

if args.check {
#[allow(clippy::cast_possible_truncation)]
let total = files.len() as u32;
let already = total - unformatted;
let summary = if unformatted == 0 {
format!("{total} file(s) already formatted")
} else {
format!("{unformatted} file(s) would be reformatted, {already} already formatted")
};
println!(
"{}",
colorize(
&summary,
if unformatted == 0 { "1;32" } else { "1;33" },
use_color,
)
);
if unformatted > 0 {
std::process::exit(1);
}
}

Ok(())
}

fn print_config_schema() -> Result<()> {
let schema = serde_json::json!({
"$schema": "https://json-schema.org/draft-07/schema",
Expand Down Expand Up @@ -365,8 +462,10 @@ fn main() -> Result<()> {
return print_config_schema();
}

if let Some(Command::Check(ref args)) = cli.command {
return run_check(args, &cli);
match cli.command {
Some(Command::Check(ref args)) => return run_check(args, &cli),
Some(Command::Format(ref args)) => return run_format(args, &cli),
None => {}
}

init_tracing(&cli)?;
Expand Down
117 changes: 117 additions & 0 deletions tests/format_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use std::process::Command;

use tempfile::TempDir;

fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_vimdoc-language-server"))
}

fn short_sep() -> String {
"=".repeat(30)
}

fn full_sep() -> String {
"=".repeat(78)
}

#[test]
fn format_in_place() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("help.txt");
std::fs::write(&file, format!("{}\nHello world\n", short_sep())).unwrap();

let output = bin().arg("format").arg(&file).output().unwrap();

assert!(output.status.success());
let result = std::fs::read_to_string(&file).unwrap();
assert!(
result.starts_with(&full_sep()),
"separator should be normalized to full width"
);
}

#[test]
fn format_check_passes_when_formatted() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("help.txt");
std::fs::write(&file, format!("{}\nHello world\n", full_sep())).unwrap();

let output = bin()
.args(["format", "--check"])
.arg(&file)
.output()
.unwrap();

assert!(output.status.success());
}

#[test]
fn format_check_fails_when_unformatted() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("help.txt");
std::fs::write(&file, format!("{}\nHello world\n", short_sep())).unwrap();

let output = bin()
.args(["format", "--check"])
.arg(&file)
.output()
.unwrap();

assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Would reformat"));
}

#[test]
fn format_check_does_not_modify_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("help.txt");
let original = format!("{}\nHello world\n", short_sep());
std::fs::write(&file, &original).unwrap();

bin()
.args(["format", "--check"])
.arg(&file)
.output()
.unwrap();

let after = std::fs::read_to_string(&file).unwrap();
assert_eq!(after, original);
}

#[test]
fn format_directory() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("help.txt");
std::fs::write(&file, format!("{}\nHello world\n", short_sep())).unwrap();

let output = bin().arg("format").arg(dir.path()).output().unwrap();

assert!(output.status.success());
let result = std::fs::read_to_string(&file).unwrap();
assert!(result.starts_with(&full_sep()));
}

#[test]
fn format_no_files_errors() {
let output = bin().arg("format").output().unwrap();

assert!(!output.status.success());
}

#[test]
fn format_respects_line_width() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("help.txt");
std::fs::write(&file, format!("{}\n", short_sep())).unwrap();

bin()
.args(["--line-width", "40", "format"])
.arg(&file)
.output()
.unwrap();

let result = std::fs::read_to_string(&file).unwrap();
let first_line = result.lines().next().unwrap();
assert_eq!(first_line.len(), 40);
}
Loading