chore: add orgize-{cli,common,lsp} package

This commit is contained in:
PoiScript 2023-12-20 21:56:10 +08:00
parent 6930640866
commit 4cc1130a17
No known key found for this signature in database
GPG key ID: 22C2B1249D99985E
131 changed files with 6577 additions and 56 deletions

View file

@ -45,12 +45,12 @@ jobs:
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build
run: wasm-pack build -t web -d ./dist --out-name orgize ./wasm/
run: wasm-pack build -t web -d ./dist --out-name orgize ./orgize-wasm/
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
with:
path: "./wasm"
path: "./orgize-wasm"
- name: Deploy to GitHub Pages
id: deployment

2
.gitignore vendored
View file

@ -2,6 +2,6 @@
**/*.rs.bk
Cargo.lock
benches/*.org
.vscode
.gdb_history
perf.data*

View file

@ -1,49 +1,21 @@
[package]
name = "orgize"
[workspace]
resolver = "2"
members = [
".",
"./orgize",
"./orgize-cli",
"./orgize-common",
"./orgize-lsp",
"./orgize-wasm",
]
[workspace.package]
version = "0.10.0-alpha.6"
authors = ["PoiScript <poiscript@gmail.com>"]
description = "A Rust library for parsing org-mode files."
repository = "https://github.com/PoiScript/orgize"
readme = "README.md"
edition = "2018"
edition = "2021"
license = "MIT"
keywords = ["orgmode", "org-mode", "emacs", "parser"]
exclude = ["/wasm", "/.github"]
[package.metadata.docs.rs]
all-features = true
[features]
default = []
indexmap = ["dep:indexmap"]
chrono = ["dep:chrono"]
[workspace]
members = [".", "./wasm"]
[dependencies]
bytecount = "0.6"
chrono = { version = "0.4", optional = true }
indexmap = { version = "2.1", optional = true }
jetscii = "0.5"
memchr = "2.5"
nom = { version = "7.1", default-features = false, features = ["std"] }
rowan = "0.15"
tracing = "0.1"
[dev-dependencies]
criterion = "0.5"
insta = "1.29"
slugify = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
[profile.dev.package]
insta.opt-level = 3
similar.opt-level = 3
[profile.bench]
debug = true
[[bench]]
name = "parse"
harness = false

25
orgize-cli/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "orgize-cli"
version.workspace = true
authors.workspace = true
repository.workspace = true
edition.workspace = true
license.workspace = true
description = "CLI tools for org-mode file, powered by orgize."
[dependencies]
anyhow = "1.0.75"
clap = { version = "4.4.11", features = ["derive"] }
clap-verbosity-flag = "2.1.0"
jetscii = "0.5.3"
nom = "7.1.3"
orgize = { path = "../orgize" }
orgize-common = { path = "../orgize-common" }
resolve-path = "0.1.0"
tempfile = "3.8.1"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
[[bin]]
name = "orgize"
path = "src/main.rs"

View file

@ -0,0 +1,80 @@
use clap::Args;
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
Org,
};
use std::path::PathBuf;
use crate::diff;
#[derive(Debug, Args)]
pub struct Command {
path: Vec<PathBuf>,
#[arg(short, long)]
dry_run: bool,
}
impl Command {
pub fn run(self) -> anyhow::Result<()> {
for path in self.path {
if !path.exists() {
tracing::error!("{:?} is not existed", path);
}
let orgi = std::fs::read_to_string(&path)?;
let mut t = DetangleTraverser {
results: Vec::new(),
org_file_path: path,
};
let org = Org::parse(&orgi);
org.traverse(&mut t);
if self.dry_run {
diff::print(&orgi, t.results);
} else {
diff::write_to_file(&orgi, t.results, t.org_file_path)?;
}
}
Ok(())
}
}
struct DetangleTraverser {
results: Vec<(usize, usize, String)>,
org_file_path: PathBuf,
}
impl Traverser for DetangleTraverser {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::SourceBlock(block)) => {
if let Ok(Some((start, end, content))) =
orgize_common::detangle(block, &self.org_file_path)
{
self.results.push((start, end, content));
}
ctx.skip();
}
// skip some containers for performance
Event::Enter(Container::List(_))
| Event::Enter(Container::OrgTable(_))
| Event::Enter(Container::SpecialBlock(_))
| Event::Enter(Container::QuoteBlock(_))
| Event::Enter(Container::CenterBlock(_))
| Event::Enter(Container::VerseBlock(_))
| Event::Enter(Container::CommentBlock(_))
| Event::Enter(Container::ExampleBlock(_))
| Event::Enter(Container::ExportBlock(_)) => {
ctx.skip();
}
_ => {}
}
}
}

46
orgize-cli/src/diff.rs Normal file
View file

@ -0,0 +1,46 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use clap::builder::styling::{AnsiColor, Color, Style};
pub fn print(orgi: &str, mut patches: Vec<(usize, usize, String)>) {
patches.sort_by(|a, b| a.0.cmp(&b.0));
let mut off = 0;
for (start, end, content) in patches {
print!("{}", &orgi[off..(start)]);
if orgi[start..end] != content {
let style = Style::new().fg_color(Color::Ansi(AnsiColor::Cyan).into());
print!("{}{}{}", style.render(), &content, style.render_reset());
} else {
print!("{}", &content);
}
off = end;
}
print!("{}", &orgi[off..]);
}
pub fn write_to_file(
orgi: &str,
mut patches: Vec<(usize, usize, String)>,
path: PathBuf,
) -> anyhow::Result<()> {
patches.sort_by(|a, b| a.0.cmp(&b.0));
let file = &mut OpenOptions::new().write(true).open(path)?;
let mut off = 0;
for (start, end, content) in patches {
write!(file, "{}{}", &orgi[off..start], &content)?;
off = end;
}
write!(file, "{}", &orgi[off..])?;
Ok(())
}

View file

@ -0,0 +1,86 @@
use clap::Args;
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
Org,
};
use std::path::PathBuf;
use crate::diff;
#[derive(Debug, Args)]
pub struct Command {
path: Vec<PathBuf>,
#[arg(short, long)]
dry_run: bool,
}
impl Command {
pub fn run(self) -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
tracing::debug!("Create tempdir {:?}", dir.path().to_string_lossy());
for path in self.path {
if !path.exists() {
tracing::error!("{:?} is not existed", path);
}
let mut t = ExecuteTraverser {
results: Vec::new(),
dir: &dir,
};
let orgi = std::fs::read_to_string(&path)?;
let org = Org::parse(&orgi);
org.traverse(&mut t);
t.results.sort_by(|a, b| a.0.cmp(&b.0));
if self.dry_run {
diff::print(&orgi, t.results);
} else {
diff::write_to_file(&orgi, t.results, path)?;
}
}
Ok(())
}
}
struct ExecuteTraverser<'a> {
results: Vec<(usize, usize, String)>,
dir: &'a tempfile::TempDir,
}
impl<'a> Traverser for ExecuteTraverser<'a> {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::SourceBlock(block)) => {
if let Ok(Some((start, end, content))) =
orgize_common::execute(block, self.dir.path())
{
self.results.push((start, end, content));
}
ctx.skip();
}
// skip some containers for performance
Event::Enter(Container::List(_))
| Event::Enter(Container::OrgTable(_))
| Event::Enter(Container::SpecialBlock(_))
| Event::Enter(Container::QuoteBlock(_))
| Event::Enter(Container::CenterBlock(_))
| Event::Enter(Container::VerseBlock(_))
| Event::Enter(Container::CommentBlock(_))
| Event::Enter(Container::ExampleBlock(_))
| Event::Enter(Container::ExportBlock(_)) => {
ctx.skip();
}
_ => {}
}
}
}

54
orgize-cli/src/main.rs Normal file
View file

@ -0,0 +1,54 @@
mod detangle;
mod diff;
mod execute_src_block;
mod tangle;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, LevelFilter as CLevelFilter, Verbosity};
use tracing::level_filters::LevelFilter;
#[derive(Debug, Parser)]
#[clap(name = "orgize-tools", version)]
pub struct App {
#[clap(subcommand)]
command: Command,
#[command(flatten)]
verbose: Verbosity<InfoLevel>,
}
#[derive(Debug, Subcommand)]
enum Command {
#[clap(name = "tangle")]
Tangle(tangle::Command),
#[clap(name = "detangle")]
Detangle(detangle::Command),
#[clap(name = "execute-src-block")]
ExecuteSrcBlock(execute_src_block::Command),
}
fn main() -> anyhow::Result<()> {
let parsed = App::parse();
tracing_subscriber::fmt()
.with_max_level(match parsed.verbose.log_level_filter() {
CLevelFilter::Off => LevelFilter::OFF,
CLevelFilter::Error => LevelFilter::ERROR,
CLevelFilter::Warn => LevelFilter::WARN,
CLevelFilter::Info => LevelFilter::INFO,
CLevelFilter::Debug => LevelFilter::DEBUG,
CLevelFilter::Trace => LevelFilter::TRACE,
})
.without_time()
.with_file(false)
.with_line_number(false)
.init();
match parsed.command {
Command::Tangle(cmd) => cmd.run(),
Command::Detangle(cmd) => cmd.run(),
Command::ExecuteSrcBlock(cmd) => cmd.run(),
}
}

111
orgize-cli/src/tangle.rs Normal file
View file

@ -0,0 +1,111 @@
use clap::{
builder::styling::{AnsiColor, Color, Style},
Args,
};
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
Org,
};
use std::fs;
use std::{collections::HashMap, path::PathBuf};
#[derive(Debug, Args)]
pub struct Command {
path: Vec<PathBuf>,
#[arg(short, long)]
dry_run: bool,
}
impl Command {
pub fn run(self) -> anyhow::Result<()> {
let mut t = TangleTraverser::default();
for path in self.path {
if !path.exists() {
tracing::error!("{:?} is not existed", path);
}
let string = std::fs::read_to_string(&path)?;
let org = Org::parse(string);
t.org_file_path = path;
t.count = 0;
org.traverse(&mut t);
tracing::info!(
"Found {} code block from {}",
t.count,
t.org_file_path.to_string_lossy()
);
}
if self.dry_run {
for (path, (permission, content, mkdir)) in t.results {
let style = Style::new()
.fg_color(Color::Ansi(AnsiColor::BrightYellow).into())
.underline()
.bold();
print!(
"{}{}{}",
style.render(),
path.to_string_lossy(),
style.render_reset(),
);
if let Some(permission) = permission {
print!(" (permission: {:o})", permission);
}
if mkdir {
print!(" (mkdir: yes)");
}
println!("\n{}", content);
}
} else {
for (path, (_, contents, _)) in t.results {
fs::write(&path, contents)?;
tracing::info!("Wrote to {}", path.to_string_lossy());
}
}
Ok(())
}
}
#[derive(Default)]
struct TangleTraverser {
results: HashMap<PathBuf, (Option<u32>, String, bool)>,
count: usize,
org_file_path: PathBuf,
}
impl Traverser for TangleTraverser {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::SourceBlock(block)) => {
if let Ok(Some((path, permission, content, mkdir))) =
orgize_common::tangle(block, &self.org_file_path)
{
let value = self.results.entry(path).or_default();
value.0 = permission;
value.1.push_str(&content);
value.2 = mkdir;
}
ctx.skip();
}
// skip some containers for performance
Event::Enter(Container::List(_))
| Event::Enter(Container::OrgTable(_))
| Event::Enter(Container::SpecialBlock(_))
| Event::Enter(Container::QuoteBlock(_))
| Event::Enter(Container::CenterBlock(_))
| Event::Enter(Container::VerseBlock(_))
| Event::Enter(Container::CommentBlock(_))
| Event::Enter(Container::ExampleBlock(_))
| Event::Enter(Container::ExportBlock(_)) => {
ctx.skip();
}
_ => {}
}
}
}

16
orgize-common/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "orgize-common"
version.workspace = true
authors.workspace = true
repository.workspace = true
edition.workspace = true
license.workspace = true
description = "Shared code between orgize-lsp and orgize-lsp."
[dependencies]
anyhow = "1.0.75"
jetscii = "0.5.3"
nom = "7.1.3"
orgize = { path = "../orgize" }
resolve-path = "0.1.0"
tracing = "0.1.40"

View file

@ -0,0 +1,110 @@
use orgize::{
ast::{Headline, SourceBlock},
rowan::ast::AstNode,
SyntaxKind,
};
use resolve_path::PathResolveExt;
use std::{fs, path::PathBuf};
use crate::{
header_argument::{header_argument, property_drawer, property_keyword},
utils::language_comments,
};
pub fn detangle(
block: SourceBlock,
file_path: &PathBuf,
) -> anyhow::Result<Option<(usize, usize, String)>> {
let arg1 = block.parameters().unwrap_or_default();
let arg2 = property_drawer(block.syntax()).unwrap_or_default();
let arg3 = property_keyword(block.syntax()).unwrap_or_default();
let language = block.language().unwrap_or_default();
let tangle = header_argument(&arg1, &arg2, &arg3, ":tangle", "no");
if tangle == "no" {
return Ok(None);
}
let comments = header_argument(&arg1, &arg2, &arg3, ":comments", "no");
let parent = block
.syntax()
.ancestors()
.find(|n| n.kind() == SyntaxKind::HEADLINE || n.kind() == SyntaxKind::DOCUMENT);
let nth = parent
.as_ref()
.and_then(|n| n.children().position(|c| &c == block.syntax()))
.unwrap_or(1);
let headline_title = parent.and_then(Headline::cast).map(|headline| {
headline
.title()
.fold(String::new(), |a, n| a + &n.to_string())
});
let dest_path = tangle.try_resolve_in(file_path)?.to_path_buf();
let content = fs::read_to_string(dest_path)?;
let Some(begin) = block
.syntax()
.children()
.find(|n| n.kind() == SyntaxKind::BLOCK_CONTENT)
else {
return Ok(None);
};
let text_range = begin.text_range();
if comments == "yes" || comments == "link" || comments == "noweb" || comments == "both" {
let begin_comments = format!(
"[[file:{path}::*{title}][{title}:{nth}]]",
title = headline_title.as_deref().unwrap_or("No heading"),
path = file_path.to_string_lossy(),
);
let end_comments = format!(
"{title}:{nth} ends here",
title = headline_title.as_deref().unwrap_or("No heading"),
);
let mut block_content = String::new();
for line in content
.lines()
.skip_while(|line| trim_comments(line, &language).unwrap_or_default() != begin_comments)
.skip(1)
{
if trim_comments(line, &language).unwrap_or_default() == end_comments {
return Ok(Some((
text_range.start().into(),
text_range.end().into(),
block_content,
)));
} else {
block_content += line;
block_content += "\n";
}
}
tracing::warn!(
"Cannot found contents wrapped by comments for code block {path}*{title}:{nth}.",
title = headline_title.as_deref().unwrap_or("No heading"),
path = file_path.to_string_lossy(),
);
return Ok(None);
}
Ok(Some((
text_range.start().into(),
text_range.end().into(),
content,
)))
}
fn trim_comments<'a>(input: &'a str, language: &str) -> Option<&'a str> {
let (begin, end) = language_comments(language)?;
Some(input.trim().strip_prefix(begin)?.strip_suffix(end)?.trim())
}

View file

@ -0,0 +1,142 @@
use orgize::{
ast::{AffiliatedKeyword, SourceBlock},
rowan::ast::AstNode,
SyntaxKind,
};
use std::{fs::File, io::Write, iter::once, path::Path, process};
use crate::{
header_argument::{header_argument, property_drawer, property_keyword},
utils::language_execute_command,
};
#[derive(Debug)]
enum Format {
Code,
List,
Verbatim,
Html,
Latex,
Raw,
}
pub fn execute(block: SourceBlock, path: &Path) -> anyhow::Result<Option<(usize, usize, String)>> {
let arg1 = block.parameters().unwrap_or_default();
let arg2 = property_drawer(block.syntax()).unwrap_or_default();
let arg3 = property_keyword(block.syntax()).unwrap_or_default();
let language = block.language().unwrap_or_default();
let results = header_argument(&arg1, &arg2, &arg3, ":results", "no");
if results == "no" {
return Ok(None);
}
let Some(command) = language_execute_command(&language) else {
anyhow::bail!("{language:?} is not supported.")
};
let mut segs = results.split(&[' ', '\t']).filter(|x| !x.is_empty());
let format = match (segs.next(), segs.next()) {
(Some("output"), Some("code")) | (Some("code"), None) => Format::Code,
(Some("output"), Some("list")) | (Some("list"), None) => Format::List,
(Some("output"), Some("scalar"))
| (Some("scalar"), None)
| (Some("output"), Some("verbatim"))
| (Some("verbatim"), None) => Format::Verbatim,
(Some("output"), Some("html")) | (Some("html"), None) => Format::Html,
(Some("output"), Some("latex")) | (Some("latex"), None) => Format::Latex,
(Some("output"), Some("raw")) | (Some("raw"), None) => Format::Raw,
(Some("value"), _) => anyhow::bail!("{language:?} is not supported."),
_ => return Ok(None),
};
let results = collect_output(command, &block.value(), format, path)?;
if let Some((start, end)) = find_existing_results(&block) {
Ok(Some((start, end, results)))
} else {
let start = block.end() as usize;
Ok(Some((start, start, format!("\n#+RESULTS:\n{}\n", results))))
}
}
fn collect_output(
command: &str,
value: &str,
format: Format,
path: &Path,
) -> anyhow::Result<String> {
let path = path.join("orgize-temporary");
let mut file = File::create(&path)?;
file.write_all(value.as_bytes())?;
let output = process::Command::new(command).arg(path).output()?;
let output = String::from_utf8_lossy(&output.stdout);
match format {
Format::Code => Ok(once("#+begin_src")
.chain(output.lines())
.chain(once("#+end_src"))
.fold(String::new(), |acc, line| acc + line + "\n")),
Format::Html => Ok(once("#+begin_export html")
.chain(output.lines())
.chain(once("#+end_export"))
.fold(String::new(), |acc, line| acc + line + "\n")),
Format::Latex => Ok(once("#+begin_export latex")
.chain(output.lines())
.chain(once("#+end_export"))
.fold(String::new(), |acc, line| acc + line + "\n")),
Format::List => Ok(output
.lines()
.fold(String::new(), |acc, line| acc + "- " + line + "\n")),
Format::Verbatim => Ok(output
.lines()
.fold(String::new(), |acc, line| acc + ": " + line + "\n")),
Format::Raw => Ok(output.to_string()),
}
}
fn find_existing_results(block: &SourceBlock) -> Option<(usize, usize)> {
let results = block
.syntax()
.next_sibling()
.filter(|n| {
matches!(
n.kind(),
SyntaxKind::ORG_TABLE
| SyntaxKind::FIXED_WIDTH
| SyntaxKind::LIST
| SyntaxKind::SOURCE_BLOCK
| SyntaxKind::EXPORT_BLOCK
)
})
.filter(|n| {
n.children()
.filter_map(AffiliatedKeyword::cast)
.any(|k| k.key().eq_ignore_ascii_case("results"))
})?;
let mut iter = results
.children_with_tokens()
.skip_while(|n| n.kind() == SyntaxKind::AFFILIATED_KEYWORD)
.take_while(|n| n.kind() != SyntaxKind::BLANK_LINE);
let first = iter.next();
let last = iter.last();
let start = first.as_ref().map(|n| n.text_range().start())?;
let end = last.or(first).map(|x| x.text_range().end())?;
Some((start.into(), end.into()))
}

View file

@ -0,0 +1,133 @@
use orgize::{
ast::Rule,
export::{Container, Event, TraversalContext, Traverser},
rowan::ast::AstNode,
Org, SyntaxKind, SyntaxNode,
};
pub fn formatting(org: &Org) -> Vec<(usize, usize, String)> {
let mut format = FormattingTraverser { edits: vec![] };
org.traverse(&mut format);
format.edits
}
struct FormattingTraverser {
edits: Vec<(usize, usize, String)>,
}
impl Traverser for FormattingTraverser {
fn event(&mut self, event: Event, _: &mut TraversalContext) {
match event {
Event::Rule(rule) => {
format_rule(&rule, &mut self.edits);
format_blank_lines(rule.syntax(), &mut self.edits);
}
Event::Clock(clock) => {
format_blank_lines(clock.syntax(), &mut self.edits);
}
Event::Enter(Container::Document(document)) => {
format_blank_lines(document.syntax(), &mut self.edits);
}
Event::Enter(Container::Paragraph(paragraph)) => {
format_blank_lines(paragraph.syntax(), &mut self.edits);
}
Event::Enter(Container::List(list)) => {
format_blank_lines(list.syntax(), &mut self.edits);
}
Event::Enter(Container::OrgTable(table)) => {
format_blank_lines(table.syntax(), &mut self.edits);
}
Event::Enter(Container::SpecialBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
Event::Enter(Container::QuoteBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
Event::Enter(Container::CenterBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
Event::Enter(Container::VerseBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
Event::Enter(Container::CommentBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
Event::Enter(Container::ExampleBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
Event::Enter(Container::ExportBlock(block)) => {
format_blank_lines(block.syntax(), &mut self.edits);
}
_ => {}
}
}
}
fn format_rule(rule: &Rule, edits: &mut Vec<(usize, usize, String)>) {
let node = rule.syntax();
for token in node.children_with_tokens().filter_map(|e| e.into_token()) {
if token.kind() == SyntaxKind::WHITESPACE && !token.text().is_empty() {
edits.push((
token.text_range().start().into(),
token.text_range().end().into(),
"".into(),
));
}
if token.kind() == SyntaxKind::TEXT && token.text().len() != 5 {
edits.push((
token.text_range().start().into(),
token.text_range().end().into(),
"-----".into(),
));
}
if token.kind() == SyntaxKind::NEW_LINE && token.text() != "\n" {
edits.push((
token.text_range().start().into(),
token.text_range().end().into(),
"\n".into(),
));
}
}
}
fn format_blank_lines(node: &SyntaxNode, edits: &mut Vec<(usize, usize, String)>) {
let mut blank_lines = node
.children_with_tokens()
.filter_map(|e| e.into_token())
.filter(|n| n.kind() == SyntaxKind::BLANK_LINE);
if let Some(line) = blank_lines.next() {
if line.text() != "\n" {
edits.push((
line.text_range().start().into(),
line.text_range().end().into(),
"\n".into(),
));
}
}
match (blank_lines.next(), blank_lines.last()) {
(Some(first), Some(last)) => {
edits.push((
first.text_range().start().into(),
last.text_range().end().into(),
"".into(),
));
}
(Some(first), None) => {
edits.push((
first.text_range().start().into(),
first.text_range().end().into(),
"".into(),
));
}
_ => {}
}
}

View file

@ -0,0 +1,111 @@
use jetscii::Substring;
use nom::{
bytes::complete::take_while1,
character::complete::{space0, space1},
InputTake,
};
use orgize::{
ast::{Headline, Keyword, Token},
rowan::ast::AstNode,
SyntaxKind, SyntaxNode,
};
pub fn header_argument<'a>(
arg1: &'a str,
arg2: &'a str,
arg3: &'a str,
key: &str,
default: &'static str,
) -> &'a str {
extract_header_args(arg1, key)
.or_else(|_| extract_header_args(arg2, key))
.or_else(|_| extract_header_args(arg3, key))
.unwrap_or(default)
}
pub fn property_keyword(node: &SyntaxNode) -> Option<Token> {
node.ancestors()
.find(|n| n.kind() == SyntaxKind::DOCUMENT)
.and_then(|n| n.first_child())
.filter(|n| n.kind() == SyntaxKind::SECTION)
.and_then(|n| {
n.children()
.filter_map(Keyword::cast)
.filter(|kw| kw.key().eq_ignore_ascii_case("PROPERTY"))
.map(|kw| kw.value())
.find(|value| value.trim_start().starts_with("header-args "))
})
}
pub fn property_drawer(node: &SyntaxNode) -> Option<Token> {
node.ancestors()
.find_map(Headline::cast)
.and_then(|hdl| hdl.properties())
.and_then(|drawer| drawer.get("header-args"))
}
pub fn extract_header_args<'a>(input: &'a str, key: &str) -> Result<&'a str, nom::Err<()>> {
let mut i = input;
while !i.is_empty() {
let (input, _) = space0(i)?;
let (input, name) = take_while1(|c| c != ' ' && c != '\t')(input)?;
if !name.eq_ignore_ascii_case(key) {
debug_assert!(input.len() < i.len(), "{} < {}", input.len(), i.len());
i = input;
continue;
}
let (input, _) = space1(input)?;
if let Some(idx) = Substring::new(" :")
.find(input)
.or_else(|| Substring::new("\t:").find(input))
{
let idx = input[0..idx]
.rfind(|c| c != ' ' && c != '\t')
.map(|i| i + 1)
.unwrap_or(idx);
let (_, value) = input.take_split(idx);
return Ok(value.trim());
} else {
return Ok(input.trim());
}
}
Err(nom::Err::Error(()))
}
#[test]
fn parse_header_args() {
assert!(extract_header_args("", ":tangle").is_err());
assert!(extract_header_args(" :noweb yes", ":tangle1").is_err());
assert!(extract_header_args(":tangle", ":tangle").is_err());
assert_eq!(extract_header_args(":tangle ", ":tangle").unwrap(), "");
assert_eq!(
extract_header_args(":tangle emacs.d/init.el", ":tangle").unwrap(),
"emacs.d/init.el"
);
assert_eq!(
extract_header_args(" :tangle emacs.d/init.el", ":tangle").unwrap(),
"emacs.d/init.el"
);
assert_eq!(
extract_header_args(" :tangle emacs.d/init.el :noweb yes", ":tangle").unwrap(),
"emacs.d/init.el"
);
assert_eq!(
extract_header_args(" :noweb yes :tangle emacs.d/init.el", ":tangle").unwrap(),
"emacs.d/init.el"
);
assert_eq!(
extract_header_args(":results output code", ":results").unwrap(),
"output code"
);
}

13
orgize-common/src/lib.rs Normal file
View file

@ -0,0 +1,13 @@
mod detangle;
mod execute_src_block;
mod formatting;
mod header_argument;
mod tangle;
mod utils;
pub use detangle::detangle;
pub use execute_src_block::execute;
pub use formatting::formatting;
pub use header_argument::*;
pub use tangle::tangle;
pub use utils::headline_slug;

130
orgize-common/src/tangle.rs Normal file
View file

@ -0,0 +1,130 @@
// TODO: :noweb support
use orgize::{
ast::{Headline, SourceBlock},
rowan::{ast::AstNode, Direction},
SyntaxKind,
};
use resolve_path::PathResolveExt;
use std::fmt::Write;
use std::path::PathBuf;
use crate::{
header_argument::{header_argument, property_drawer, property_keyword},
utils::language_comments,
};
pub fn tangle(
block: SourceBlock,
path: &PathBuf,
) -> anyhow::Result<Option<(PathBuf, Option<u32>, String, bool)>> {
let arg1 = block.parameters().unwrap_or_default();
let arg2 = property_drawer(block.syntax()).unwrap_or_default();
let arg3 = property_keyword(block.syntax()).unwrap_or_default();
let language = block.language().unwrap_or_default();
let tangle = header_argument(&arg1, &arg2, &arg3, ":tangle", "no");
if tangle == "no" {
return Ok(None);
}
let comments = header_argument(&arg1, &arg2, &arg3, ":comments", "no");
let padline = header_argument(&arg1, &arg2, &arg3, ":padline", "no");
let shebang = header_argument(&arg1, &arg2, &arg3, ":shebang", "no");
let mode = header_argument(
&arg1,
&arg2,
&arg3,
":tangle-mode",
if shebang == "yea" { "o755" } else { "no" },
);
let is_mkdir = header_argument(&arg1, &arg2, &arg3, ":mkdir", "no");
let parent = block
.syntax()
.ancestors()
.find(|n| n.kind() == SyntaxKind::HEADLINE || n.kind() == SyntaxKind::DOCUMENT);
let nth = parent
.as_ref()
.and_then(|n| n.children().position(|c| &c == block.syntax()))
.unwrap_or(1);
let headline_title = parent.and_then(Headline::cast).map(|headline| {
headline
.title()
.fold(String::new(), |a, n| a + &n.to_string())
});
let path = tangle.try_resolve_in(path)?.to_path_buf();
let mut permission = None;
let mut content = String::new();
if mode != "no"
&& mode.len() == 4
&& mode.starts_with('o')
&& mode.bytes().skip(1).all(|b| (b'0'..=b'7').contains(&b))
{
permission = u32::from_str_radix(&mode[1..], 8).ok();
}
if shebang != "no" && !shebang.is_empty() {
content.push_str(shebang);
}
if comments == "org" || comments == "both" {
if let Some((begin, end)) = language_comments(&language) {
let start = block
.syntax()
.siblings(Direction::Prev)
.skip(1) // skip self
.take_while(|n| n.kind() != SyntaxKind::SOURCE_BLOCK)
.last();
for sibling in start
.into_iter()
.flat_map(|start| start.siblings(Direction::Next))
.take_while(|node| node != block.syntax())
{
for line in sibling.to_string().lines() {
if line.is_empty() {
let _ = writeln!(content);
} else {
let _ = writeln!(content, "{begin} {line} {end}");
}
}
}
}
}
if comments == "yes" || comments == "link" || comments == "noweb" || comments == "both" {
if let Some((begin, end)) = language_comments(&language) {
let _ = writeln!(
content,
"{begin} [[file:{path}::*{title}][{title}:{nth}]] {end}",
title = headline_title.as_deref().unwrap_or("No heading"),
path = path.to_string_lossy(),
);
}
}
content.push_str(&block.value());
if padline != "no" {
let _ = writeln!(content);
}
if comments == "yes" || comments == "link" || comments == "noweb" || comments == "both" {
if let Some((begin, end)) = language_comments(&language) {
let _ = writeln!(
content,
"{begin} {title}:{nth} ends here {end}",
title = headline_title.as_deref().unwrap_or("No heading"),
);
}
}
Ok(Some((path, permission, content, is_mkdir != "no")))
}

View file

@ -0,0 +1,35 @@
use orgize::ast::Headline;
pub fn language_comments(language: &str) -> Option<(&str, &str)> {
match language {
"c" | "cpp" | "c++" | "go" | "js" | "javascript" | "ts" | "typescript" | "rust"
| "vera" | "jsonc" => Some(("//", "")),
"toml" | "tml" | "yaml" | "yml" | "conf" | "gitconfig" | "conf-toml" | "sh" | "shell"
| "bash" | "zsh" | "fish" => Some(("#", "")),
"lua" | "sql" => Some(("--", "")),
"lisp" | "emacs-lisp" | "elisp" => Some((";;", "")),
"xml" | "html" | "svg" => Some(("<!--", "-->")),
_ => None,
}
}
pub fn language_execute_command(language: &str) -> Option<&str> {
match language {
"js" | "javascript" => Some("node"),
"sh" | "bash" => Some("bash"),
"py" | "python" => Some("python"),
"fish" => Some("fish"),
_ => None,
}
}
pub fn headline_slug(headline: &Headline) -> String {
headline.title().fold(String::new(), |mut acc, elem| {
for ch in elem.to_string().chars() {
if ch.is_ascii_graphic() {
acc.push(ch);
}
}
acc
})
}

25
orgize-lsp/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "orgize-lsp"
version.workspace = true
authors.workspace = true
repository.workspace = true
edition.workspace = true
license.workspace = true
description = "Language server for Org-mode, powered by orgize."
[dependencies]
orgize = { path = "../orgize" }
orgize-common = { path = "../orgize-common" }
tokio = { version = "1.17", features = ["full"] }
tower-lsp = { version = "0.20", features = ["proposed"] }
dashmap = "5.1"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0"
tempfile = "3.8"
resolve-path = "0.1"
memchr = "2.6"
[[bin]]
name = "orgize-lsp"
path = "src/main.rs"

50
orgize-lsp/README.md Normal file
View file

@ -0,0 +1,50 @@
# `orgize-lsp`
Language server for org-mode, powered by orgize.
## Install
### Server
```sh
$ cargo install --path .
```
### Client (vscode)
```sh
$ pnpm run -C editors/vscode package --no-dependencies
$ code --install-extension ./editors/vscode/orgize-lsp.vsix --force
```
## Supported features
1. Folding range
- Fold headline, list, table, blocks
2. Document symbols
- Headings
3. Formatting
4. Document link
- File links
- Source block `:tangle` arguments
- Internal links
5. Code lens
- Generate toc heading
- Tangle/detanlge source block
- Evaluate source block
6. Commands
- Show syntax tree

3
orgize-lsp/editors/vscode/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
dist
*.vsix
node_modules

View file

@ -0,0 +1,6 @@
**
!dist/
!syntaxes/
!package.json
!org.configuration.json
!README.md

View file

@ -0,0 +1 @@
../../LICENSE

View file

@ -0,0 +1,13 @@
import * as esbuild from "esbuild";
await esbuild.build({
bundle: true,
entryPoints: ["src/main.ts"],
external: ["vscode"],
outfile: "dist/main.js",
format: "cjs",
platform: "node",
target: "node16",
minify: true,
treeShaking: true,
});

View file

@ -0,0 +1,29 @@
{
"comments": {
"lineComment": "#",
"blockComment": ["#+BEGIN_COMMENT\n", "\n#+END_COMMENT"]
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""]
],
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""],
["'", "'"],
["*", "*"],
["/", "/"],
["_", "_"],
["=", "="],
["~", "~"]
]
}

View file

@ -0,0 +1,111 @@
{
"name": "orgize-lsp",
"private": true,
"version": "0.0.0-dev",
"engines": {
"vscode": "^1.75.0"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/PoiScript/orgize"
},
"scripts": {
"vscode:prepublish": "node build.mjs",
"package": "vsce package -o orgize-lsp.vsix --skip-license --no-dependencies",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/node": "~16.11.68",
"@types/vscode": "~1.75.1",
"@vscode/test-electron": "^2.3.8",
"@vscode/vsce": "^2.22.0",
"esbuild": "^0.18.20",
"ovsx": "^0.8.3",
"prettier": "^3.1.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3"
},
"main": "./dist/main",
"contributes": {
"commands": [
{
"command": "orgize.syntax-tree",
"title": "(Debug) Show org syntax tree"
}
],
"languages": [
{
"id": "org",
"aliases": [
"Org",
"Org Markup",
"Org Mode"
],
"extensions": [
".org"
],
"configuration": "./org.configuration.json"
}
],
"grammars": [
{
"language": "org",
"scopeName": "source.org",
"path": "./syntaxes/org.tmLanguage.json",
"embeddedLanguages": {
"meta.embedded.block.html": "html",
"source.js": "javascript",
"source.css": "css",
"meta.embedded.block.frontmatter": "yaml",
"meta.embedded.block.css": "css",
"meta.embedded.block.ini": "ini",
"meta.embedded.block.java": "java",
"meta.embedded.block.lua": "lua",
"meta.embedded.block.makefile": "makefile",
"meta.embedded.block.perl": "perl",
"meta.embedded.block.r": "r",
"meta.embedded.block.ruby": "ruby",
"meta.embedded.block.php": "php",
"meta.embedded.block.sql": "sql",
"meta.embedded.block.vs_net": "vs_net",
"meta.embedded.block.xml": "xml",
"meta.embedded.block.xsl": "xsl",
"meta.embedded.block.yaml": "yaml",
"meta.embedded.block.dosbatch": "dosbatch",
"meta.embedded.block.clojure": "clojure",
"meta.embedded.block.coffee": "coffee",
"meta.embedded.block.c": "c",
"meta.embedded.block.cpp": "cpp",
"meta.embedded.block.diff": "diff",
"meta.embedded.block.dockerfile": "dockerfile",
"meta.embedded.block.go": "go",
"meta.embedded.block.groovy": "groovy",
"meta.embedded.block.pug": "jade",
"meta.embedded.block.javascript": "javascript",
"meta.embedded.block.json": "json",
"meta.embedded.block.jsonc": "jsonc",
"meta.embedded.block.latex": "latex",
"meta.embedded.block.less": "less",
"meta.embedded.block.objc": "objc",
"meta.embedded.block.scss": "scss",
"meta.embedded.block.perl6": "perl6",
"meta.embedded.block.powershell": "powershell",
"meta.embedded.block.python": "python",
"meta.embedded.block.rust": "rust",
"meta.embedded.block.scala": "scala",
"meta.embedded.block.shellscript": "shellscript",
"meta.embedded.block.typescript": "typescript",
"meta.embedded.block.typescriptreact": "typescriptreact",
"meta.embedded.block.csharp": "csharp",
"meta.embedded.block.fsharp": "fsharp"
}
}
]
}
}

1268
orgize-lsp/editors/vscode/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
import { ExtensionContext } from "vscode";
import {
Executable,
LanguageClient,
LanguageClientOptions,
ServerOptions,
} from "vscode-languageclient/node";
import SyntaxTreeProvider from "./syntax-tree";
import PreviewHtmlProvider from "./preview-html";
export let client: LanguageClient;
export function activate(context: ExtensionContext) {
// If the extension is launched in debug mode then the debug server options are used
// Otherwise the run options are used
const run: Executable = {
command: "orgize-lsp",
};
const serverOptions: ServerOptions = {
run,
debug: run,
};
// Options to control the language client
const clientOptions: LanguageClientOptions = {
// Register the server for plain text documents
documentSelector: [{ scheme: "file", language: "org" }],
};
// Create the language client and start the client.
client = new LanguageClient(
"orgize-lsp",
"Orgize LSP",
serverOptions,
clientOptions
);
// Start the client. This will also launch the server
client.start();
context.subscriptions.push(SyntaxTreeProvider.register());
context.subscriptions.push(PreviewHtmlProvider.register());
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}

View file

@ -0,0 +1,234 @@
import {
Disposable,
ExtensionContext,
TextDocumentContentProvider,
Uri,
ViewColumn,
Webview,
WebviewOptions,
WebviewPanel,
commands,
window,
workspace,
} from "vscode";
import { client } from "./main";
export const register = (context: ExtensionContext) => {
const provider = new PreviewHtmlProvider();
context.subscriptions.push(
workspace.registerTextDocumentContentProvider(
"orgize-lsp-preview",
provider
)
);
};
export default class PreviewHtmlProvider
implements TextDocumentContentProvider
{
static readonly scheme = "orgize-preview-html";
static register(): Disposable {
const provider = new PreviewHtmlProvider();
// register content provider for scheme `references`
// register document link provider for scheme `references`
const providerRegistrations = workspace.registerTextDocumentContentProvider(
PreviewHtmlProvider.scheme,
provider
);
// register command that crafts an uri with the `references` scheme,
// open the dynamic document, and shows it in the next editor
const commandRegistration = commands.registerTextEditorCommand(
"orgize.preview-html",
(editor) => {
return workspace
.openTextDocument(encode(editor.document.uri))
.then((doc) => window.showTextDocument(doc, editor.viewColumn! + 1));
}
);
return Disposable.from(
provider,
commandRegistration,
providerRegistrations
);
}
dispose() {
// this._subscriptions.dispose();
// this._documents.clear();
// this._editorDecoration.dispose();
// this._onDidChange.dispose();
}
async provideTextDocumentContent(uri: Uri): Promise<string> {
if (!client) {
return "LSP server is not ready...";
}
return client.sendRequest("workspace/executeCommand", {
command: "orgize.syntax-tree",
arguments: [uri.toString()],
});
}
}
class PreviewHtmlPanel {
/**
* Track the currently panel. Only allow a single panel to exist at a time.
*/
public static currentPanel: PreviewHtmlPanel | undefined;
public static readonly viewType = "orgizePreviewHtml";
private readonly _panel: WebviewPanel;
private readonly _extensionUri: Uri;
private _disposables: Disposable[] = [];
public static createOrShow(uri: Uri) {
const column = window.activeTextEditor
? window.activeTextEditor.viewColumn
: undefined;
// If we already have a panel, show it.
if (PreviewHtmlPanel.currentPanel) {
PreviewHtmlPanel.currentPanel._panel.reveal(column);
return;
}
// Otherwise, create a new panel.
const panel = window.createWebviewPanel(
PreviewHtmlPanel.viewType,
"Preview of " + uri.fsPath,
column || ViewColumn.One,
getWebviewOptions(uri)
);
PreviewHtmlPanel.currentPanel = new PreviewHtmlPanel(panel, uri);
}
public static revive(panel: WebviewPanel, extensionUri: Uri) {
PreviewHtmlPanel.currentPanel = new PreviewHtmlPanel(panel, extensionUri);
}
private constructor(panel: WebviewPanel, extensionUri: Uri) {
this._panel = panel;
this._extensionUri = extensionUri;
// Set the webview's initial html content
this._update();
// Listen for when the panel is disposed
// This happens when the user closes the panel or when the panel is closed programmatically
this._panel.onDidDispose(
() => {
this.dispose();
},
null,
this._disposables
);
// Update the content based on view changes
this._panel.onDidChangeViewState(
(e) => {
if (this._panel.visible) {
this._update();
}
},
null,
this._disposables
);
}
public dispose() {
PreviewHtmlPanel.currentPanel = undefined;
// Clean up our resources
this._panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _update() {
const webview = this._panel.webview;
this._panel.webview.html = this._getHtmlForWebview(webview);
}
private _getHtmlForWebview(webview: Webview): string {
// // Local path to main script run in the webview
// const scriptPathOnDisk = Uri.joinPath(
// this._extensionUri,
// "media",
// "main.js"
// );
// // And the uri we use to load this script in the webview
// const scriptUri = webview.asWebviewUri(scriptPathOnDisk);
// // Local path to css styles
// const styleResetPath = Uri.joinPath(
// this._extensionUri,
// "media",
// "reset.css"
// );
// const stylesPathMainPath = Uri.joinPath(
// this._extensionUri,
// "media",
// " css"
// );
// // Uri to load styles into webview
// const stylesResetUri = webview.asWebviewUri(styleResetPath);
// const stylesMainUri = webview.asWebviewUri(stylesPathMainPath);
// Use a nonce to only allow specific scripts to be run
// const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cat Coding</title>
</head>
<body>
<img width="300" />
<h1 id="lines-of-code-counter">0</h1>
</body>
</html>`;
}
}
const getWebviewOptions = (extensionUri: Uri): WebviewOptions => {
return {
// Enable javascript in the webview
enableScripts: true,
// And restrict the webview to only loading content from our extension's `media` directory.
localResourceRoots: [Uri.joinPath(extensionUri, "media")],
};
};
const encode = (uri: Uri): Uri => {
return uri.with({
scheme: PreviewHtmlProvider.scheme,
query: uri.path,
path: "tree.syntax",
});
};
const decode = (uri: Uri): Uri => {
return uri.with({ scheme: "file", path: uri.query, query: "" });
};

View file

@ -0,0 +1,77 @@
import {
Disposable,
TextDocumentContentProvider,
Uri,
commands,
window,
workspace,
} from "vscode";
import { client } from "./main";
export default class SyntaxTreeProvider implements TextDocumentContentProvider {
static readonly scheme = "orgize-syntax-tree";
static register(): Disposable {
const provider = new SyntaxTreeProvider();
// register content provider for scheme `references`
// register document link provider for scheme `references`
const providerRegistrations = workspace.registerTextDocumentContentProvider(
SyntaxTreeProvider.scheme,
provider
);
// register command that crafts an uri with the `references` scheme,
// open the dynamic document, and shows it in the next editor
const commandRegistration = commands.registerTextEditorCommand(
"orgize.syntax-tree",
(editor) => {
return workspace
.openTextDocument(encode(editor.document.uri))
.then((doc) => window.showTextDocument(doc, editor.viewColumn! + 1));
}
);
return Disposable.from(
provider,
commandRegistration,
providerRegistrations
);
}
dispose() {
// this._subscriptions.dispose();
// this._documents.clear();
// this._editorDecoration.dispose();
// this._onDidChange.dispose();
}
async provideTextDocumentContent(uri: Uri): Promise<string> {
if (!client) {
return "LSP server is not ready...";
}
const result = await client.sendRequest("workspace/executeCommand", {
command: "orgize.syntax-tree",
arguments: [decode(uri).toString()],
});
if (typeof result === "string") {
return result;
}
return "";
}
}
const encode = (uri: Uri): Uri => {
return uri.with({
scheme: SyntaxTreeProvider.scheme,
query: uri.path,
path: "tree.syntax",
});
};
const decode = (uri: Uri): Uri => {
return uri.with({ scheme: "file", path: uri.query, query: "" });
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"lib": ["ES2019"],
"outDir": "../dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", ".vscode-test"]
}

8
orgize-lsp/justfile Normal file
View file

@ -0,0 +1,8 @@
a: c s
c:
pnpm run -C client-vscode package --no-dependencies
pnpm run -C client-vscode install --no-dependencies
s:
cargo install --path ./server --offline

104
orgize-lsp/src/code_lens.rs Normal file
View file

@ -0,0 +1,104 @@
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
rowan::ast::AstNode,
};
use orgize_common::{header_argument, property_drawer, property_keyword};
use tower_lsp::lsp_types::{CodeLens, Url};
use crate::org_document::OrgDocument;
use super::OrgizeCommand;
pub struct CodeLensTraverser<'a> {
pub url: Url,
pub doc: &'a OrgDocument,
pub lens: Vec<CodeLens>,
}
impl<'a> Traverser for CodeLensTraverser<'a> {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::SourceBlock(block)) => {
let start = block.begin();
let arg1 = block.parameters().unwrap_or_default();
let arg2 = property_drawer(block.syntax()).unwrap_or_default();
let arg3 = property_keyword(block.syntax()).unwrap_or_default();
let range = self.doc.range_of(start, start);
let tangle = header_argument(&arg1, &arg2, &arg3, ":tangle", "no");
if header_argument(&arg1, &arg2, &arg3, ":results", "no") != "no" {
self.lens.push(CodeLens {
range,
command: Some(
OrgizeCommand::SrcBlockExecute {
block_offset: start,
url: self.url.clone(),
}
.into(),
),
data: None,
});
}
if tangle != "no" {
self.lens.push(CodeLens {
range,
command: Some(
OrgizeCommand::SrcBlockTangle {
block_offset: start,
url: self.url.clone(),
}
.into(),
),
data: None,
});
self.lens.push(CodeLens {
range,
command: Some(
OrgizeCommand::SrcBlockDetangle {
block_offset: start,
url: self.url.clone(),
}
.into(),
),
data: None,
});
}
ctx.skip();
}
Event::Enter(Container::Headline(headline)) => {
if headline.tags().any(|t| t.eq_ignore_ascii_case("TOC")) {
let start = headline.begin();
self.lens.push(CodeLens {
range: self.doc.range_of(start, start),
command: Some(
OrgizeCommand::HeadlineToc {
heading_offset: start,
url: self.url.clone(),
}
.into(),
),
data: None,
});
}
}
_ => {}
}
}
}
impl<'a> CodeLensTraverser<'a> {
pub fn new(url: Url, doc: &'a OrgDocument) -> Self {
CodeLensTraverser {
url,
lens: vec![],
doc,
}
}
}

View file

@ -0,0 +1,101 @@
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
rowan::ast::AstNode,
SyntaxKind,
};
use std::collections::HashMap;
use std::fmt::Write;
use tower_lsp::lsp_types::{TextEdit, Url, WorkspaceEdit};
use crate::Backend;
impl Backend {
pub async fn headline_toc(&self, url: Url, headline_offset: u32) {
let uri = url.to_string();
let Some(doc) = self.documents.get(&uri) else {
return;
};
let mut toc = Toc {
indent: 0,
output: String::new(),
headline_offset,
edit_range: None,
};
doc.traverse(&mut toc);
if let Some((start, end)) = toc.edit_range {
let mut changes = HashMap::new();
let range = doc.range_of(start, end);
changes.insert(
url,
vec![TextEdit {
new_text: toc.output,
range,
}],
);
let _ = self
.client
.apply_edit(WorkspaceEdit {
changes: Some(changes),
..Default::default()
})
.await;
}
}
}
pub struct Toc {
output: String,
indent: usize,
headline_offset: u32,
edit_range: Option<(u32, u32)>,
}
impl Traverser for Toc {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::Headline(headline)) => {
if headline.begin() == self.headline_offset {
let start = headline
.syntax()
.children_with_tokens()
.find(|n| n.kind() == SyntaxKind::NEW_LINE)
.map(|n| n.text_range().end().into())
.unwrap_or(headline.end());
let end = headline.end();
self.edit_range = Some((start, end));
} else {
let title = headline.title().map(|e| e.to_string()).collect::<String>();
let slug = orgize_common::headline_slug(&headline);
let _ = writeln!(
&mut self.output,
"{: >i$}- [[#{slug}][{title}]]",
"",
i = self.indent
);
}
self.indent += 2;
}
Event::Leave(Container::Headline(_)) => self.indent -= 2,
Event::Enter(Container::Section(_)) => ctx.skip(),
Event::Enter(Container::Document(_)) => self.output += "#+begin_quote\n",
Event::Leave(Container::Document(_)) => self.output += "#+end_quote\n\n",
_ => {}
}
}
}

View file

@ -0,0 +1,116 @@
mod headline_toc;
mod src_block_detangle;
mod src_block_execute;
mod src_block_tangle;
use orgize::rowan::ast::AstNode;
use serde_json::{json, Value};
use tower_lsp::lsp_types::{Command, ExecuteCommandParams, MessageType, Url};
use crate::Backend;
pub enum OrgizeCommand {
SrcBlockExecute { url: Url, block_offset: u32 },
SrcBlockTangle { url: Url, block_offset: u32 },
SrcBlockDetangle { url: Url, block_offset: u32 },
HeadlineToc { url: Url, heading_offset: u32 },
}
impl From<OrgizeCommand> for Command {
fn from(val: OrgizeCommand) -> Self {
match val {
OrgizeCommand::SrcBlockExecute { url, block_offset } => Command {
command: "orgize.src-block.execute".into(),
arguments: Some(vec![json!(url), json!(block_offset)]),
title: "Execute".into(),
},
OrgizeCommand::SrcBlockTangle { url, block_offset } => Command {
command: "orgize.src-block.tangle".into(),
arguments: Some(vec![json!(url), json!(block_offset)]),
title: "Tangle".into(),
},
OrgizeCommand::SrcBlockDetangle { url, block_offset } => Command {
command: "orgize.src-block.detangle".into(),
arguments: Some(vec![json!(url), json!(block_offset)]),
title: "Detangle".into(),
},
OrgizeCommand::HeadlineToc {
url,
heading_offset,
} => Command {
command: "orgize.headline.toc".into(),
arguments: Some(vec![json!(url), json!(heading_offset)]),
title: "Generate TOC".into(),
},
}
}
}
impl OrgizeCommand {
pub fn all() -> Vec<String> {
vec![
"orgize.src-block.execute".into(),
"orgize.src-block.tangle".into(),
"orgize.src-block.detangle".into(),
"orgize.src-block.open-tangle-dest".into(),
"orgize.headline.toc".into(),
]
}
}
pub async fn execute(params: &ExecuteCommandParams, backend: &Backend) -> Option<Value> {
let result = match (
params.command.as_str(),
params.arguments.get(0).and_then(|x| x.as_str()),
params.arguments.get(1).and_then(|x| x.as_u64()),
) {
("orgize.src-block.execute", Some(s), Some(n)) => backend
.src_block_execute(s.parse().ok()?, n as u32)
.await
.map(|_| None),
("orgize.src-block.tangle", Some(s), Some(n)) => backend
.src_block_tangle(s.parse().ok()?, n as u32)
.await
.map(|_| None),
("orgize.src-block.detangle", Some(s), Some(n)) => backend
.src_block_detangle(s.parse().ok()?, n as u32)
.await
.map(|_| None),
("orgize.headline.toc", Some(s), Some(n)) => {
backend.headline_toc(s.parse().ok()?, n as u32).await;
Ok(None)
}
("orgize.syntax-tree", Some(s), _) => {
if let Some(doc) = backend.documents.get(s) {
Ok(Some(json!(format!("{:#?}", doc.org.document().syntax()))))
} else {
Ok(None)
}
}
("orgize.preview-html", Some(s), _) => {
if let Some(doc) = backend.documents.get(s) {
Ok(Some(json!(format!("{}", doc.org.to_html()))))
} else {
Ok(None)
}
}
_ => Ok(None),
};
match result {
Ok(value) => value,
Err(err) => {
backend
.client
.show_message(
MessageType::ERROR,
format!("Failed to execute {:?}: {}", params.command, err),
)
.await;
None
}
}
}

View file

@ -0,0 +1,53 @@
use std::collections::HashMap;
use orgize::{ast::SourceBlock, rowan::ast::AstNode};
use tower_lsp::lsp_types::{MessageType, TextEdit, Url, WorkspaceEdit};
use crate::Backend;
impl Backend {
pub async fn src_block_detangle(&self, url: Url, block_offset: u32) -> anyhow::Result<()> {
let uri = url.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(());
};
let Some(block) = doc
.org
.document()
.syntax()
.descendants()
.filter_map(SourceBlock::cast)
.find(|n| n.begin() == block_offset)
else {
return Ok(());
};
let Ok(file_path) = url.to_file_path() else {
return Ok(());
};
if let Some((start, end, new_text)) = orgize_common::detangle(block, &file_path)? {
let mut changes = HashMap::new();
let range = doc.range_of(start as u32, end as u32);
changes.insert(url, vec![TextEdit { new_text, range }]);
let _ = self
.client
.apply_edit(WorkspaceEdit {
changes: Some(changes),
..Default::default()
})
.await;
} else {
self.client
.show_message(MessageType::WARNING, "Code block can't be detangled.")
.await;
}
Ok(())
}
}

View file

@ -0,0 +1,51 @@
use std::collections::HashMap;
use orgize::{ast::SourceBlock, rowan::ast::AstNode};
use tower_lsp::lsp_types::{MessageType, TextEdit, Url, WorkspaceEdit};
use crate::Backend;
impl Backend {
pub async fn src_block_execute(&self, url: Url, block_offset: u32) -> anyhow::Result<()> {
let uri = url.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(());
};
let Some(block) = doc
.org
.document()
.syntax()
.descendants()
.filter_map(SourceBlock::cast)
.find(|n| n.begin() == block_offset)
else {
return Ok(());
};
let dir = tempfile::tempdir().unwrap();
if let Some((start, end, new_text)) = orgize_common::execute(block, dir.path())? {
let mut changes = HashMap::new();
let range = doc.range_of(start as u32, end as u32);
changes.insert(url, vec![TextEdit { new_text, range }]);
let _ = self
.client
.apply_edit(WorkspaceEdit {
changes: Some(changes),
..Default::default()
})
.await;
} else {
self.client
.show_message(MessageType::WARNING, "Code block can't be executed.")
.await;
}
Ok(())
}
}

View file

@ -0,0 +1,49 @@
use orgize::{ast::SourceBlock, rowan::ast::AstNode};
use std::fs;
use tower_lsp::lsp_types::{MessageType, Url};
use crate::Backend;
impl Backend {
pub async fn src_block_tangle(&self, url: Url, block_offset: u32) -> anyhow::Result<()> {
let uri = url.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(());
};
let Some(block) = doc
.org
.document()
.syntax()
.descendants()
.filter_map(SourceBlock::cast)
.find(|n| n.begin() == block_offset)
else {
return Ok(());
};
let Ok(file_path) = url.to_file_path() else {
return Ok(());
};
if let Some((dest, _permission, contents, _mkdir)) =
orgize_common::tangle(block, &file_path)?
{
fs::write(&dest, contents)?;
self.client
.show_message(
MessageType::INFO,
format!("Wrote to {}", dest.to_string_lossy()),
)
.await;
} else {
self.client
.show_message(MessageType::WARNING, "Code block can't be tangled.")
.await;
}
Ok(())
}
}

View file

@ -0,0 +1,129 @@
use orgize::{
ast::{Link, SourceBlock},
export::{Container, Event, TraversalContext, Traverser},
rowan::ast::{support, AstNode},
SyntaxKind,
};
use orgize_common::header_argument;
use resolve_path::PathResolveExt;
use serde_json::json;
use std::path::PathBuf;
use tower_lsp::lsp_types::{DocumentLink, Url};
use crate::org_document::OrgDocument;
pub struct DocumentLinkTraverser<'a> {
pub doc: &'a OrgDocument,
pub links: Vec<DocumentLink>,
pub path: Option<PathBuf>,
}
impl<'a> Traverser for DocumentLinkTraverser<'a> {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
let Some(base) = &self.path else {
return ctx.skip();
};
match event {
Event::Enter(Container::Link(link)) => {
if let Some(link) = link_path(link, base, self.doc) {
self.links.push(link);
}
ctx.skip();
}
Event::Enter(Container::SourceBlock(block)) => {
if let Some(link) = block_tangle(block, base, self.doc) {
self.links.push(link);
}
ctx.skip();
}
_ => {}
}
}
}
fn link_path(link: Link, base: &PathBuf, doc: &OrgDocument) -> Option<DocumentLink> {
let path = support::token(link.syntax(), SyntaxKind::LINK_PATH)
.or_else(|| support::token(link.syntax(), SyntaxKind::TEXT))?;
let path_str = path.text();
let (target, data) = if let Some(file) = path_str.strip_prefix("file:") {
let path = file.try_resolve_in(base).ok()?;
(Some(Url::from_file_path(path).ok()?), None)
} else if path_str.starts_with('/') || path_str.starts_with("./") || path_str.starts_with("~/")
{
let path = path_str.try_resolve_in(base).ok()?;
(Some(Url::from_file_path(path).ok()?), None)
} else if path_str.starts_with("http://") || path_str.starts_with("https://") {
(Some(Url::parse(path_str).ok()?), None)
} else if let Some(id) = path_str.strip_prefix('#') {
let url = Url::from_file_path(base).ok()?;
(
None,
Some(json!(vec![
"headline-id".to_string(),
url.to_string(),
id.to_string()
])),
)
} else {
return None;
};
Some(DocumentLink {
range: doc.range_of(
path.text_range().start().into(),
path.text_range().end().into(),
),
tooltip: Some("Jump to link".into()),
target,
data,
})
}
fn block_tangle(block: SourceBlock, base: &PathBuf, doc: &OrgDocument) -> Option<DocumentLink> {
let parameters = block
.syntax()
.children()
.find(|e| e.kind() == SyntaxKind::BLOCK_BEGIN)
.into_iter()
.flat_map(|n| n.children_with_tokens())
.filter_map(|n| n.into_token())
.find(|n| n.kind() == SyntaxKind::SRC_BLOCK_PARAMETERS)?;
let tangle = header_argument(parameters.text(), "", "", ":tangle", "no");
if tangle == "no" {
return None;
}
let path = tangle.try_resolve_in(base).ok()?;
let url = Url::from_file_path(path).ok()?;
let start: u32 = parameters.text_range().start().into();
let index = parameters.text().find(tangle).unwrap_or_default() as u32;
let len = tangle.len() as u32;
Some(DocumentLink {
range: doc.range_of(start + index, start + index + len),
tooltip: Some("Jump to tangle destination".into()),
target: Some(url),
data: None,
})
}
impl<'a> DocumentLinkTraverser<'a> {
pub fn new(doc: &'a OrgDocument, path: Option<PathBuf>) -> Self {
DocumentLinkTraverser {
path,
links: vec![],
doc,
}
}
}
pub fn resolve() {}

View file

@ -0,0 +1,81 @@
use orgize::export::{Container, Event, TraversalContext, Traverser};
use tower_lsp::lsp_types::{DocumentLink, Url};
use crate::{org_document::OrgDocument, Backend};
pub fn document_link_resolve(
document_link: &DocumentLink,
backend: &Backend,
) -> Option<DocumentLink> {
// don't need to resolve
if document_link.target.is_some() {
return None;
}
let data = document_link.data.as_ref()?;
let data = data.as_array()?;
match (
data.get(0)?.as_str()?,
data.get(1)?.as_str()?,
data.get(2)?.as_str()?,
) {
("headline-id", url, id) => {
let mut parsed = Url::parse(url).ok()?;
let doc = backend.documents.get(url)?;
let mut h = HeadlineIdTraverser {
id: id.to_string(),
line_number: None,
doc: &doc,
};
doc.traverse(&mut h);
if let Some(line) = h.line_number.take() {
// results is zero-based
parsed.set_fragment(Some(&(line + 1).to_string()));
Some(DocumentLink {
target: Some(parsed),
data: None,
tooltip: None,
range: document_link.range,
})
} else {
Some(DocumentLink {
target: Some(parsed),
data: None,
tooltip: None,
range: document_link.range,
})
}
}
_ => None,
}
}
struct HeadlineIdTraverser<'a> {
id: String,
line_number: Option<u32>,
doc: &'a OrgDocument,
}
impl<'a> Traverser for HeadlineIdTraverser<'a> {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
if self.line_number.is_some() {
return ctx.stop();
}
match event {
Event::Enter(Container::Document(_)) => {}
Event::Enter(Container::Headline(headline)) => {
let slug = orgize_common::headline_slug(&headline);
if slug == self.id {
let line = self.doc.line_of(headline.begin());
self.line_number = Some(line + 1)
}
}
Event::Enter(Container::Section(_)) => ctx.skip(),
_ => {}
}
}
}

View file

@ -0,0 +1,66 @@
#![allow(deprecated)]
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
rowan::ast::AstNode,
SyntaxKind,
};
use tower_lsp::lsp_types::{DocumentSymbol, SymbolKind};
use crate::org_document::OrgDocument;
pub struct DocumentSymbolTraverser<'a> {
pub doc: &'a OrgDocument,
pub stack: Vec<usize>,
pub symbols: Vec<DocumentSymbol>,
}
impl<'a> Traverser for DocumentSymbolTraverser<'a> {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::Headline(headline)) => {
let mut symbols = &mut self.symbols;
for &i in &self.stack {
symbols = symbols[i].children.get_or_insert(vec![]);
}
let name = headline
.syntax()
.children_with_tokens()
.take_while(|n| n.kind() != SyntaxKind::NEW_LINE)
.map(|n| n.to_string())
.collect::<String>();
let start = headline.begin();
let end = headline.end() - 1;
self.stack.push(symbols.len());
symbols.push(DocumentSymbol {
children: None,
name,
detail: None,
kind: SymbolKind::STRING,
tags: Some(vec![]),
range: self.doc.range_of(start, end),
selection_range: self.doc.range_of(start, end),
deprecated: None,
});
}
Event::Leave(Container::Headline(_)) => {
self.stack.pop();
}
Event::Enter(Container::Section(_)) => ctx.skip(),
_ => {}
}
}
}
impl<'a> DocumentSymbolTraverser<'a> {
pub fn new(doc: &'a OrgDocument) -> Self {
DocumentSymbolTraverser {
doc,
stack: vec![],
symbols: vec![],
}
}
}

View file

@ -0,0 +1,94 @@
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
rowan::ast::AstNode,
SyntaxKind, SyntaxNode,
};
use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind};
use crate::org_document::OrgDocument;
pub struct FoldingRangeTraverser<'a> {
pub doc: &'a OrgDocument,
pub ranges: Vec<FoldingRange>,
}
impl<'a> Traverser for FoldingRangeTraverser<'a> {
fn event(&mut self, event: Event, _: &mut TraversalContext) {
let syntax = match &event {
Event::Enter(Container::Headline(i)) => i.syntax(),
Event::Enter(Container::OrgTable(i)) => i.syntax(),
Event::Enter(Container::TableEl(i)) => i.syntax(),
Event::Enter(Container::List(i)) => i.syntax(),
Event::Enter(Container::Drawer(i)) => i.syntax(),
Event::Enter(Container::DynBlock(i)) => i.syntax(),
Event::Enter(Container::SpecialBlock(i)) => i.syntax(),
Event::Enter(Container::QuoteBlock(i)) => i.syntax(),
Event::Enter(Container::CenterBlock(i)) => i.syntax(),
Event::Enter(Container::VerseBlock(i)) => i.syntax(),
Event::Enter(Container::CommentBlock(i)) => i.syntax(),
Event::Enter(Container::ExampleBlock(i)) => i.syntax(),
Event::Enter(Container::ExportBlock(i)) => i.syntax(),
Event::Enter(Container::SourceBlock(i)) => i.syntax(),
_ => return,
};
let (start, end) = if syntax.kind() == SyntaxKind::HEADLINE {
let range = syntax.text_range();
(range.start().into(), range.end().into())
} else {
get_block_folding_range(syntax)
};
let start_line = self.doc.line_of(start);
let end_line = self.doc.line_of(end - 1);
if start_line != end_line {
self.ranges.push(FoldingRange {
start_line,
end_line,
kind: Some(FoldingRangeKind::Region),
..Default::default()
});
}
}
}
fn get_block_folding_range(syntax: &SyntaxNode) -> (u32, u32) {
let start: u32 = syntax.text_range().start().into();
// don't include blank lines in folding range
let end = syntax
.children()
.take_while(|n| n.kind() != SyntaxKind::BLANK_LINE)
.last();
let end: u32 = end.map(|n| n.text_range().end().into()).unwrap_or(start);
(start, end)
}
impl<'a> FoldingRangeTraverser<'a> {
pub fn new(doc: &'a OrgDocument) -> Self {
FoldingRangeTraverser {
ranges: vec![],
doc,
}
}
}
#[test]
fn test() {
let doc = OrgDocument::new("\n* a\n\n* b\n\n");
let mut t = FoldingRangeTraverser::new(&doc);
doc.traverse(&mut t);
assert_eq!(t.ranges[0].start_line, 1);
assert_eq!(t.ranges[0].end_line, 2);
assert_eq!(t.ranges[1].start_line, 3);
assert_eq!(t.ranges[1].end_line, 4);
let doc = OrgDocument::new("\n\r\n#+begin_src\n#+end_src\n\r\r");
let mut t = FoldingRangeTraverser::new(&doc);
doc.traverse(&mut t);
assert_eq!(t.ranges[0].start_line, 2);
assert_eq!(t.ranges[0].end_line, 3);
}

View file

@ -0,0 +1,13 @@
use tower_lsp::lsp_types::TextEdit;
use crate::org_document::OrgDocument;
pub fn formatting(doc: &OrgDocument) -> Vec<TextEdit> {
orgize_common::formatting(&doc.org)
.into_iter()
.map(|(start, end, content)| TextEdit {
range: doc.range_of(start as u32, end as u32),
new_text: content,
})
.collect::<Vec<_>>()
}

285
orgize-lsp/src/main.rs Normal file
View file

@ -0,0 +1,285 @@
mod code_lens;
mod commands;
mod document_link;
mod document_link_resolve;
mod document_symbol;
mod folding_range;
mod formatting;
mod org_document;
mod semantic_token;
use dashmap::DashMap;
use document_symbol::DocumentSymbolTraverser;
use org_document::OrgDocument;
use serde_json::Value;
use tower_lsp::lsp_types::*;
use tower_lsp::{jsonrpc::Result, Client, LanguageServer, LspService, Server};
pub use self::code_lens::*;
pub use self::commands::*;
pub use self::document_link::*;
pub use self::folding_range::*;
pub use self::semantic_token::*;
pub struct Backend {
client: Client,
documents: DashMap<String, OrgDocument>,
}
#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> {
Ok(InitializeResult {
server_info: None,
offset_encoding: None,
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
execute_command_provider: Some(ExecuteCommandOptions {
commands: OrgizeCommand::all(),
work_done_progress_options: Default::default(),
}),
workspace: Some(WorkspaceServerCapabilities {
workspace_folders: Some(WorkspaceFoldersServerCapabilities {
supported: Some(true),
change_notifications: Some(OneOf::Left(true)),
}),
file_operations: None,
}),
semantic_tokens_provider: Some(
SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(
SemanticTokensRegistrationOptions {
text_document_registration_options: {
TextDocumentRegistrationOptions {
document_selector: Some(vec![DocumentFilter {
language: Some("org".to_string()),
scheme: Some("file".to_string()),
pattern: None,
}]),
}
},
semantic_tokens_options: SemanticTokensOptions {
work_done_progress_options: WorkDoneProgressOptions::default(),
legend: SemanticTokensLegend {
token_types: LEGEND_TYPE.into(),
token_modifiers: vec![],
},
range: Some(true),
full: Some(SemanticTokensFullOptions::Bool(true)),
},
static_registration_options: StaticRegistrationOptions::default(),
},
),
),
code_lens_provider: Some(CodeLensOptions {
resolve_provider: Some(true),
}),
folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
document_link_provider: Some(DocumentLinkOptions {
resolve_provider: Some(true),
work_done_progress_options: WorkDoneProgressOptions::default(),
}),
document_formatting_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
..ServerCapabilities::default()
},
})
}
async fn initialized(&self, _: InitializedParams) {
self.client
.log_message(MessageType::INFO, "Orgize LSP initialized")
.await;
}
async fn shutdown(&self) -> Result<()> {
self.client
.log_message(MessageType::INFO, "Orgize LSP shutdown")
.await;
Ok(())
}
async fn did_open(&self, params: DidOpenTextDocumentParams) {
let url = params.text_document.uri.to_string();
self.documents
.insert(url.clone(), OrgDocument::new(params.text_document.text));
}
async fn did_change(&self, params: DidChangeTextDocumentParams) {
let url = params.text_document.uri.to_string();
for change in params.content_changes {
if let (Some(mut doc), Some(range)) = (self.documents.get_mut(&url), change.range) {
let start = doc.offset_of(range.start);
let end = doc.offset_of(range.end);
doc.update(start, end, &change.text);
} else {
self.documents
.insert(url.clone(), OrgDocument::new(change.text));
}
}
}
async fn did_save(&self, _: DidSaveTextDocumentParams) {}
async fn did_close(&self, _: DidCloseTextDocumentParams) {}
async fn did_change_configuration(&self, _: DidChangeConfigurationParams) {}
async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) {}
async fn did_change_watched_files(&self, _: DidChangeWatchedFilesParams) {}
async fn completion(&self, _: CompletionParams) -> Result<Option<CompletionResponse>> {
Ok(None)
}
async fn semantic_tokens_full(
&self,
params: SemanticTokensParams,
) -> Result<Option<SemanticTokensResult>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let mut traverser = SemanticTokenTraverser::new(&doc);
doc.traverse(&mut traverser);
Ok(Some(SemanticTokensResult::Tokens(SemanticTokens {
result_id: None,
data: traverser.tokens,
})))
}
async fn semantic_tokens_range(
&self,
params: SemanticTokensRangeParams,
) -> Result<Option<SemanticTokensRangeResult>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let mut traverser = SemanticTokenTraverser::with_range(&doc, params.range);
doc.traverse(&mut traverser);
Ok(Some(SemanticTokensRangeResult::Partial(
SemanticTokensPartialResult {
data: traverser.tokens,
},
)))
}
async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let mut traverser =
DocumentLinkTraverser::new(&doc, params.text_document.uri.to_file_path().ok());
doc.traverse(&mut traverser);
Ok(Some(traverser.links))
}
async fn document_link_resolve(&self, params: DocumentLink) -> Result<DocumentLink> {
if let Some(link) = document_link_resolve::document_link_resolve(&params, self) {
Ok(link)
} else {
Ok(params)
}
}
async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let mut traverser = FoldingRangeTraverser::new(&doc);
doc.traverse(&mut traverser);
Ok(Some(traverser.ranges))
}
async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let mut traverser = CodeLensTraverser::new(params.text_document.uri, &doc);
doc.traverse(&mut traverser);
Ok(Some(traverser.lens))
}
async fn code_lens_resolve(&self, params: CodeLens) -> Result<CodeLens> {
Ok(params)
}
async fn code_action(&self, _: CodeActionParams) -> Result<Option<CodeActionResponse>> {
Ok(None)
}
async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let edits = formatting::formatting(&doc);
Ok(Some(edits))
}
async fn execute_command(&self, params: ExecuteCommandParams) -> Result<Option<Value>> {
let value = commands::execute(&params, self).await;
Ok(value)
}
async fn document_symbol(
&self,
params: DocumentSymbolParams,
) -> Result<Option<DocumentSymbolResponse>> {
let uri = params.text_document.uri.to_string();
let Some(doc) = self.documents.get(&uri) else {
return Ok(None);
};
let mut t = DocumentSymbolTraverser::new(&doc);
doc.traverse(&mut t);
dbg!(&t.symbols);
Ok(Some(DocumentSymbolResponse::Nested(t.symbols)))
}
}
#[tokio::main]
async fn main() {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::build(|client| Backend {
client,
documents: DashMap::new(),
})
.finish();
Server::new(stdin, stdout, socket).serve(service).await;
}

View file

@ -0,0 +1,128 @@
use orgize::{export::Traverser, Org};
use std::iter::once;
use tower_lsp::lsp_types::{Position, Range};
pub struct OrgDocument {
pub text: String,
pub line_starts: Vec<u32>,
pub org: Org,
}
impl OrgDocument {
pub fn new(text: impl AsRef<str>) -> Self {
let text = text.as_ref().to_string();
OrgDocument {
org: Org::parse(&text),
line_starts: line_starts(&text),
text,
}
}
pub fn update(&mut self, start: u32, end: u32, text: &str) {
self.text
.replace_range((start as usize)..(end as usize), text);
self.line_starts = line_starts(&self.text);
self.org = Org::parse(&self.text);
}
pub fn position_of(&self, offset: u32) -> Position {
let line = self
.line_starts
.binary_search(&offset)
.unwrap_or_else(|i| i - 1);
let line_start = self.line_starts[line];
let character = self.text.as_str()[(line_start as usize)..(offset as usize)]
.chars()
.count();
Position::new(line as u32, character as u32)
}
pub fn line_of(&self, offset: u32) -> u32 {
self.line_starts
.binary_search(&offset)
.unwrap_or_else(|i| i - 1) as u32
}
pub fn range_of(&self, start_offset: u32, end_offset: u32) -> Range {
Range::new(self.position_of(start_offset), self.position_of(end_offset))
}
pub fn offset_of(&self, position: Position) -> u32 {
let line_start = self.line_starts[position.line as usize] as usize;
let index = self.text.as_str()[line_start..]
.char_indices()
.nth(position.character as usize)
.map(|(i, _)| i)
.unwrap_or_default();
(line_start + index) as u32
}
pub fn traverse<H: Traverser>(&self, h: &mut H) {
self.org.traverse(h);
}
}
fn line_starts(text: &str) -> Vec<u32> {
let bytes = text.as_bytes();
once(0)
.chain(
memchr::memchr2_iter(b'\r', b'\n', bytes)
.filter(|&i| bytes[i] == b'\n' || !matches!(bytes.get(i + 1), Some(b'\n')))
.map(|i| (i + 1) as u32),
)
.collect()
}
#[test]
fn test() {
let doc = OrgDocument::new(
r#"* toc :toc:
fsfs
fasdfs
fasdfs
*a* _a_ /1/ ~default~ =default= a_a
# abc
* abc12121
12121
#+begin_src javascript
console.log(a);
#+end_src
"#,
);
let start = 12;
let start_position = Position {
line: 1,
character: 0,
};
let end = 81;
let end_position = Position {
line: 13,
character: 0,
};
assert_eq!(doc.position_of(start), start_position);
assert_eq!(doc.position_of(end), end_position);
assert_eq!(doc.offset_of(start_position), start);
assert_eq!(doc.offset_of(end_position), end);
}

View file

@ -0,0 +1,123 @@
use orgize::{
export::{Container, Event, TraversalContext, Traverser},
rowan::{ast::AstNode, TextRange},
};
use tower_lsp::lsp_types::{Range, SemanticToken, SemanticTokenType};
use crate::org_document::OrgDocument;
/// Semantic token types that are used for highlighting
pub const LEGEND_TYPE: &[SemanticTokenType] = &[SemanticTokenType::COMMENT];
pub struct SemanticTokenTraverser<'a> {
pub doc: &'a OrgDocument,
pub range: Option<TextRange>,
pub tokens: Vec<SemanticToken>,
pub previous_line: u32,
pub previous_start: u32,
}
impl<'a> Traverser for SemanticTokenTraverser<'a> {
fn event(&mut self, event: Event, ctx: &mut TraversalContext) {
match event {
Event::Enter(Container::Comment(comment)) => {
let range = comment.syntax().text_range();
if self.contains_range(range) {
if let Some(token) = self.create_token(
comment.begin(),
comment.end(),
SemanticTokenType::COMMENT,
) {
self.tokens.push(token);
}
}
ctx.skip();
}
Event::Enter(Container::CommentBlock(comment)) => {
let range = comment.syntax().text_range();
if self.contains_range(range) {
if let Some(token) = self.create_token(
comment.begin(),
comment.end(),
SemanticTokenType::COMMENT,
) {
self.tokens.push(token);
}
}
ctx.skip();
}
_ => {}
}
}
}
impl<'a> SemanticTokenTraverser<'a> {
pub fn new(doc: &'a OrgDocument) -> Self {
SemanticTokenTraverser {
doc,
range: None,
previous_line: 0,
previous_start: 0,
tokens: vec![],
}
}
pub fn with_range(doc: &'a OrgDocument, range: Range) -> Self {
let start = doc.offset_of(range.start);
let end = doc.offset_of(range.end);
SemanticTokenTraverser {
doc,
range: Some(TextRange::new(start.into(), end.into())),
previous_line: 0,
previous_start: 0,
tokens: vec![],
}
}
fn contains_range(&self, range: TextRange) -> bool {
match self.range {
Some(r) => r.contains_range(range),
None => true,
}
}
fn create_token(
&mut self,
start: u32,
end: u32,
kind: SemanticTokenType,
) -> Option<SemanticToken> {
let length = end - start;
let token_type = LEGEND_TYPE.iter().position(|item| item == &kind)? as u32;
let line = self.doc.line_of(start);
let first = self.doc.line_of(line);
let start = self.doc.line_of(start) - first;
let delta_line = line - self.previous_line;
let delta_start = if delta_line == 0 {
start - self.previous_start
} else {
start
};
self.previous_line = line;
self.previous_start = start;
Some(SemanticToken {
delta_line,
delta_start,
length,
token_type,
token_modifiers_bitset: 0,
})
}
}

15
orgize-wasm/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "orgize-wasm"
publish = false
version.workspace = true
authors.workspace = true
repository.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
orgize = { path = "../orgize" }
wasm-bindgen = "0.2"

39
orgize/Cargo.toml Normal file
View file

@ -0,0 +1,39 @@
[package]
name = "orgize"
version.workspace = true
authors.workspace = true
description = "A Rust library for parsing org-mode files."
repository.workspace = true
readme = "README.md"
edition.workspace = true
license.workspace = true
keywords = ["orgmode", "org-mode", "emacs", "parser"]
exclude = ["/wasm", "/.github"]
[package.metadata.docs.rs]
all-features = true
[features]
default = []
indexmap = ["dep:indexmap"]
chrono = ["dep:chrono"]
[dependencies]
bytecount = "0.6"
chrono = { version = "0.4", optional = true }
indexmap = { version = "2.1", optional = true }
jetscii = "0.5"
memchr = "2.5"
nom = { version = "7.1", default-features = false, features = ["std"] }
rowan = "0.15"
tracing = "0.1"
[dev-dependencies]
criterion = "0.5"
insta = "1.29"
slugify = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
[[bench]]
name = "parse"
harness = false

71
orgize/README.md Normal file
View file

@ -0,0 +1,71 @@
# Orgize
[![Crates.io](https://img.shields.io/crates/v/orgize.svg)](https://crates.io/crates/orgize)
[![Documentation](https://docs.rs/orgize/badge.svg)](https://docs.rs/orgize)
[![Build status](https://img.shields.io/github/actions/workflow/status/PoiScript/orgize/ci.yml)](https://github.com/PoiScript/orgize/actions/workflows/ci.yml)
![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)
A Rust library for parsing org-mode files.
Live Demo: <https://poiscript.github.io/orgize/>
## Parse
To parse a org-mode string, simply invoking the `Org::parse` function:
```rust
use orgize::{Org, rowan::ast::AstNode};
let org = Org::parse("* DONE Title :tag:");
assert_eq!(
format!("{:#?}", org.document().syntax()),
r#"DOCUMENT@0..18
HEADLINE@0..18
HEADLINE_STARS@0..1 "*"
WHITESPACE@1..2 " "
HEADLINE_KEYWORD_DONE@2..6 "DONE"
WHITESPACE@6..7 " "
HEADLINE_TITLE@7..13
TEXT@7..13 "Title "
HEADLINE_TAGS@13..18
COLON@13..14 ":"
TEXT@14..17 "tag"
COLON@17..18 ":"
"#);
```
use `ParseConfig::parse` to specific a custom parse config
```rust
use orgize::{Org, ParseConfig, ast::Headline};
let config = ParseConfig {
// custom todo keywords
todo_keywords: (vec!["TASK".to_string()], vec![]),
..Default::default()
};
let org = config.parse("* TASK Title 1");
let hdl = org.first_node::<Headline>().unwrap();
assert_eq!(hdl.todo_keyword().unwrap(), "TASK");
```
## Render to html
Call the `Org::to_html` function to export org element tree to html:
```rust
use orgize::Org;
assert_eq!(
Org::parse("* title\n*section*").to_html(),
"<main><h1>title</h1><section><p><b>section</b></p></section></main>"
);
```
Checkout `examples/html-slugify.rs` on how to customizing html export process.
## Features
- **`chrono`**: adds the ability to convert `Timestamp` into `chrono::NaiveDateTime`, disabled by default.
- **`indexmap`**: adds the ability to convert `PropertyDrawer` properties into `IndexMap`, disabled by default.

1
orgize/benches/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.org

Some files were not shown because too many files have changed in this diff Show more