Skip to content

Commit c1d1fad

Browse files
authored
Add format crate (#98)
1 parent 095a842 commit c1d1fad

24 files changed

Lines changed: 1375 additions & 7 deletions

‎Cargo.lock‎

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ members = [
1414
"crates/pine-lexer",
1515
"crates/pine-ast",
1616
"crates/pine-diagnostics",
17+
"crates/pine-format",
1718
"crates/pine-parser",
1819
"crates/pine-interpreter",
1920
"crates/pine-builtin-macro",
@@ -35,6 +36,7 @@ pine-lexer = { path = "crates/pine-lexer", version = "0.1.0" }
3536
pine-parser = { path = "crates/pine-parser", version = "0.1.0" }
3637
pine-ast = { path = "crates/pine-ast", version = "0.1.0" }
3738
pine-diagnostics = { path = "crates/pine-diagnostics", version = "0.1.0" }
39+
pine-format = { path = "crates/pine-format", version = "0.1.0" }
3840
pine-interpreter = { path = "crates/pine-interpreter", version = "0.1.0" }
3941
pine-builtin-macro = { path = "crates/pine-builtin-macro", version = "0.1.0" }
4042
pine-builtins = { path = "crates/pine-builtins", version = "0.1.0" }

‎crates/pine-format/Cargo.toml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "pine-format"
3+
version.workspace = true
4+
edition = "2021"
5+
description = "An opinionated formatter for Pine Script."
6+
license.workspace = true
7+
repository.workspace = true
8+
homepage.workspace = true
9+
10+
[[bin]]
11+
name = "pine-format"
12+
path = "bin/pine-format.rs"
13+
14+
[dependencies]
15+
pine-ast = { workspace = true }
16+
pine-core = { workspace = true }
17+
pine-lexer = { workspace = true }
18+
pine-parser = { workspace = true }
19+
clap = { workspace = true }
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use clap::Parser;
2+
use std::fs;
3+
use std::io::{self, Read, Write};
4+
5+
#[derive(Parser)]
6+
#[command(name = "pine-format")]
7+
#[command(about = "Format PineScript source", long_about = None)]
8+
struct Cli {
9+
/// Input file to format. Reads from stdin when omitted or with --stdin.
10+
#[arg(value_name = "FILE")]
11+
file: Option<String>,
12+
13+
/// Read input from stdin instead of a file.
14+
#[arg(long)]
15+
stdin: bool,
16+
17+
/// Rewrite the file in place instead of printing to stdout.
18+
#[arg(short, long, requires = "file")]
19+
write: bool,
20+
}
21+
22+
fn main() {
23+
let cli = Cli::parse();
24+
25+
let input = match &cli.file {
26+
Some(filename) if !cli.stdin => fs::read_to_string(filename).unwrap_or_else(|e| {
27+
eprintln!("Error reading file '{filename}': {e}");
28+
std::process::exit(1);
29+
}),
30+
_ => {
31+
let mut buffer = String::new();
32+
io::stdin()
33+
.read_to_string(&mut buffer)
34+
.expect("Failed to read from stdin");
35+
buffer
36+
}
37+
};
38+
39+
let formatted = match pine_format::format(&input) {
40+
Ok(out) => out,
41+
Err(e) => {
42+
eprintln!("Format error: {e}");
43+
std::process::exit(1);
44+
}
45+
};
46+
47+
if cli.write {
48+
let filename = cli.file.expect("--write requires a file");
49+
fs::write(&filename, formatted).unwrap_or_else(|e| {
50+
eprintln!("Error writing file '{filename}': {e}");
51+
std::process::exit(1);
52+
});
53+
} else {
54+
io::stdout().write_all(formatted.as_bytes()).ok();
55+
}
56+
}

‎crates/pine-format/src/comments.rs‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
use std::collections::{HashMap, VecDeque};
2+
3+
use pine_lexer::{Token, TokenType};
4+
5+
/// A piece of leading trivia above a statement, in source order.
6+
pub(crate) enum Lead {
7+
Comment(String),
8+
Blank,
9+
}
10+
11+
/// The trivia recovered from the token stream: whole-line comments and blank
12+
/// lines (anchored before the statement that follows them) and trailing
13+
/// comments (kept on the code line they sit on).
14+
pub(crate) struct Comments {
15+
leading: VecDeque<(u32, Lead)>,
16+
trailing: HashMap<u32, String>,
17+
}
18+
19+
impl Comments {
20+
pub(crate) fn extract(tokens: &[Token]) -> Self {
21+
let mut leading = VecDeque::new();
22+
let mut trailing = HashMap::new();
23+
let mut last_code_line: Option<usize> = None;
24+
25+
for token in tokens {
26+
match &token.typ {
27+
TokenType::Comment(text) => {
28+
let rendered = render(text);
29+
if last_code_line == Some(token.line) {
30+
trailing.insert(token.line as u32, rendered);
31+
} else {
32+
leading.push_back((token.line as u32, Lead::Comment(rendered)));
33+
}
34+
}
35+
TokenType::BlankLine => leading.push_back((token.line as u32, Lead::Blank)),
36+
TokenType::Newline | TokenType::Indent | TokenType::Dedent | TokenType::Eof => {}
37+
_ => last_code_line = Some(token.line),
38+
}
39+
}
40+
Self { leading, trailing }
41+
}
42+
43+
/// The comments and blank lines that sit above the statement starting on
44+
/// `line`, in source order.
45+
pub(crate) fn take_leading(&mut self, line: Option<u32>) -> Vec<Lead> {
46+
let mut out = Vec::new();
47+
let Some(line) = line else { return out };
48+
while let Some((trivia_line, _)) = self.leading.front() {
49+
if *trivia_line < line {
50+
out.push(self.leading.pop_front().expect("front exists").1);
51+
} else {
52+
break;
53+
}
54+
}
55+
out
56+
}
57+
58+
/// The comment trailing the code on `line`, if any.
59+
pub(crate) fn take_trailing(&mut self, line: Option<u32>) -> Option<String> {
60+
line.and_then(|line| self.trailing.remove(&line))
61+
}
62+
63+
/// Trailing comments never anchored to a statement's code line, in source
64+
/// order. (Leftover leading trivia is drained via [`Comments::take_leading`]
65+
/// with an unbounded line.)
66+
pub(crate) fn drain_trailing(&mut self) -> Vec<String> {
67+
let mut rest: Vec<(u32, String)> = self.trailing.drain().collect();
68+
rest.sort_by_key(|(line, _)| *line);
69+
rest.into_iter().map(|(_, text)| text).collect()
70+
}
71+
}
72+
73+
/// A comment's rendered form: `//` plus its verbatim text, trailing space
74+
/// trimmed. Verbatim keeps annotations (`//@version=5`) and dividers intact.
75+
fn render(text: &str) -> String {
76+
format!("//{}", text.trim_end())
77+
}

‎crates/pine-format/src/doc.rs‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//! A Wadler/Prettier-style document algebra and its layout engine.
2+
//!
3+
//! Rules build a [`Doc`]; [`layout`] renders it, choosing for each [`Doc::Group`]
4+
//! whether it fits the target width flat or must break onto multiple lines.
5+
6+
#[derive(Clone)]
7+
pub(crate) enum Doc {
8+
Nil,
9+
Text(String),
10+
/// A space when flat, a newline (+ indent) when broken.
11+
Line,
12+
/// Nothing when flat, a newline (+ indent) when broken.
13+
Softline,
14+
/// Always a newline; forces every enclosing group to break.
15+
Hardline,
16+
Concat(Vec<Doc>),
17+
Nest(usize, Box<Doc>),
18+
Group(Box<Doc>),
19+
}
20+
21+
pub(crate) fn text(s: impl Into<String>) -> Doc {
22+
Doc::Text(s.into())
23+
}
24+
25+
pub(crate) fn concat(docs: Vec<Doc>) -> Doc {
26+
Doc::Concat(docs)
27+
}
28+
29+
pub(crate) fn nest(indent: usize, doc: Doc) -> Doc {
30+
Doc::Nest(indent, Box::new(doc))
31+
}
32+
33+
pub(crate) fn group(doc: Doc) -> Doc {
34+
Doc::Group(Box::new(doc))
35+
}
36+
37+
pub(crate) fn line() -> Doc {
38+
Doc::Line
39+
}
40+
41+
pub(crate) fn softline() -> Doc {
42+
Doc::Softline
43+
}
44+
45+
pub(crate) fn hardline() -> Doc {
46+
Doc::Hardline
47+
}
48+
49+
/// `items` interleaved with `sep`.
50+
pub(crate) fn join(sep: Doc, items: Vec<Doc>) -> Doc {
51+
let mut out = Vec::with_capacity(items.len() * 2);
52+
for (i, doc) in items.into_iter().enumerate() {
53+
if i > 0 {
54+
out.push(sep.clone());
55+
}
56+
out.push(doc);
57+
}
58+
Doc::Concat(out)
59+
}
60+
61+
#[derive(Clone, Copy, PartialEq)]
62+
enum Mode {
63+
Flat,
64+
Break,
65+
}
66+
67+
pub(crate) fn layout(doc: &Doc, width: usize) -> String {
68+
let mut out = String::new();
69+
let mut col = 0usize;
70+
let mut stack: Vec<(usize, Mode, &Doc)> = vec![(0, Mode::Break, doc)];
71+
72+
while let Some((indent, mode, doc)) = stack.pop() {
73+
match doc {
74+
Doc::Nil => {}
75+
Doc::Text(s) => {
76+
out.push_str(s);
77+
col += s.chars().count();
78+
}
79+
Doc::Concat(docs) => {
80+
for child in docs.iter().rev() {
81+
stack.push((indent, mode, child));
82+
}
83+
}
84+
Doc::Nest(n, inner) => stack.push((indent + n, mode, inner)),
85+
Doc::Line => match mode {
86+
Mode::Flat => {
87+
out.push(' ');
88+
col += 1;
89+
}
90+
Mode::Break => col = newline(&mut out, indent),
91+
},
92+
Doc::Softline => match mode {
93+
Mode::Flat => {}
94+
Mode::Break => col = newline(&mut out, indent),
95+
},
96+
Doc::Hardline => col = newline(&mut out, indent),
97+
Doc::Group(inner) => {
98+
let mode = if fits(width.saturating_sub(col), indent, inner, &stack) {
99+
Mode::Flat
100+
} else {
101+
Mode::Break
102+
};
103+
stack.push((indent, mode, inner));
104+
}
105+
}
106+
}
107+
out
108+
}
109+
110+
fn newline(out: &mut String, indent: usize) -> usize {
111+
out.push('\n');
112+
for _ in 0..indent {
113+
out.push(' ');
114+
}
115+
indent
116+
}
117+
118+
/// Whether `group_inner` laid out flat, followed by whatever is already queued,
119+
/// stays within `remaining` columns before the next line break.
120+
fn fits<'a>(
121+
remaining: usize,
122+
indent: usize,
123+
group_inner: &'a Doc,
124+
rest: &[(usize, Mode, &'a Doc)],
125+
) -> bool {
126+
let mut budget = remaining.min(isize::MAX as usize) as isize;
127+
let mut stack: Vec<(usize, Mode, &Doc)> = rest.to_vec();
128+
stack.push((indent, Mode::Flat, group_inner));
129+
130+
while budget >= 0 {
131+
let (indent, mode, doc) = match stack.pop() {
132+
Some(item) => item,
133+
None => return true,
134+
};
135+
match doc {
136+
Doc::Nil => {}
137+
Doc::Text(s) => budget -= s.chars().count() as isize,
138+
Doc::Concat(docs) => {
139+
for child in docs.iter().rev() {
140+
stack.push((indent, mode, child));
141+
}
142+
}
143+
Doc::Nest(n, inner) => stack.push((indent + n, mode, inner)),
144+
Doc::Line => match mode {
145+
Mode::Flat => budget -= 1,
146+
Mode::Break => return true,
147+
},
148+
Doc::Softline => match mode {
149+
Mode::Flat => {}
150+
Mode::Break => return true,
151+
},
152+
// A forced break ends this line, so everything up to here fit.
153+
Doc::Hardline => return true,
154+
Doc::Group(inner) => stack.push((indent, Mode::Flat, inner)),
155+
}
156+
}
157+
false
158+
}

0 commit comments

Comments
 (0)