Skip to content

Commit

Permalink
init: doc comment support
Browse files Browse the repository at this point in the history
Co-authored-by: Shahar Dawn Or <mightyiampresence@gmail.com>
  • Loading branch information
hsjobeki and mightyiam committed Feb 11, 2024
1 parent dcd764e commit dcddb1d
Show file tree
Hide file tree
Showing 19 changed files with 869 additions and 283 deletions.
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## 3.0.0

- Added support for official doc-comments (RFC145)
- Custom nixdoc format is now deprecated and will be removed eventually in future releases.

By @hsjobeki in https://github.com/nix-community/nixdoc/pull/91

### Migration from nixdoc < 2.x.x to 3.x.x

#### Double asterisk

Comments that are intended for documentation should have `/** content */`. This allows the tool to distinguish between internal-comments and documentation comments.

#### Argument documentation

As of RFC145 specified there are is no argument documentation. For single arguments such as `a:` write the documentation into the regular content of your doc-comment.

**Example:** Single argument migration

```nix
{
/**
The id function
# Arguments
- `x` (`Any`): The provided value
*/
id = x: x;
}
```

## 2.7.0

- Added support to customise the attribute set prefix, which was previously hardcoded to `lib`.
Expand Down
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,57 @@ function set.

## Comment format

Currently, identifiers are included in the documentation if they have
a preceding comment in multiline syntax `/* something */`.
This tool implements a subset of the doc-comment standard specified in [RFC-145/doc-comments](https://github.com/NixOS/rfcs/blob/master/rfcs/0145-doc-strings.md).
But, it is currently limited to generating documentation for statically analyzable attribute paths only.
In the future, it could be the role of a Nix interpreter to obtain the values to be documented and their doc-comments.

It is important to start doc-comments with the additional asterisk (`*`) -> `/**` which renders as a doc-comment.

The content of the doc-comment should be some markdown. ( See [Commonmark](https://spec.commonmark.org/0.30/) specification)

### Example

The following is an example of markdown documentation for new and current users of nixdoc.

> Sidenote: Indentation is automatically detected and should be consistent across the content.
>
> If you are used to multiline-strings (`''`) in nix this should be intuitive to follow.
````nix
{
/**
This function adds two numbers
# Example
```nix
add 4 5
=>
9
```
# Type
```
add :: Number -> Number -> Number
```
# Arguments
- a: The first number
- b: The second number
*/
add = a: b: a + b;
}
````

## Outdated Format (Legacy)

### Comment format

Identifiers are included in the documentation if they have
a preceding comment in multiline syntax `/* something */`. You should consider migrating to the new format described above.

Two special line beginnings are recognised:

Expand All @@ -22,10 +71,16 @@ Two special line beginnings are recognised:
These will result in appropriate elements being inserted into the
output.

## Function arguments
### Function arguments

Function arguments can be documented by prefixing them with a comment:

> Note: This only works in the legacy format and should not be relied on anymore.
>
> See the markdown example above how to write proper documentation.
>
> For multiple reasons we cannot continue this feature.
```
/* This function does the thing a number of times. */
myFunction =
Expand Down
110 changes: 110 additions & 0 deletions src/comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use rnix::ast::{self, AstToken};
use rnix::{match_ast, SyntaxNode};
use rowan::ast::AstNode;

/// Implements functions for doc-comments according to rfc145.
pub trait DocComment {
fn doc_text(&self) -> Option<&str>;
}

impl DocComment for ast::Comment {
/// Function returns the contents of the doc-comment, if the [ast::Comment] is a
/// doc-comment, or None otherwise.
///
/// Note: [ast::Comment] holds both the single-line and multiline comment.
///
/// /**{content}*/
/// -> {content}
///
/// It is named `doc_text` to complement [ast::Comment::text].
fn doc_text(&self) -> Option<&str> {
let text = self.syntax().text();
// Check whether this is a doc-comment
if text.starts_with(r#"/**"#) && self.text().starts_with('*') {
self.text().strip_prefix('*')
} else {
None
}
}
}

/// Function retrieves a doc-comment from the [ast::Expr]
///
/// Returns an [Option<String>] of the first suitable doc-comment.
/// Returns [None] in case no suitable comment was found.
///
/// Doc-comments can appear in two places for any expression
///
/// ```nix
/// # (1) directly before the expression (anonymous)
/// /** Doc */
/// bar: bar;
///
/// # (2) when assigning a name.
/// {
/// /** Doc */
/// foo = bar: bar;
/// }
/// ```
///
/// If the doc-comment is not found in place (1) the search continues at place (2)
/// More precisely before the NODE_ATTRPATH_VALUE (ast)
/// If no doc-comment was found in place (1) or (2) this function returns None.
pub fn get_expr_docs(expr: &SyntaxNode) -> Option<String> {
if let Some(doc) = get_doc_comment(expr) {
// Found in place (1)
doc.doc_text().map(|v| v.to_owned())
} else if let Some(ref parent) = expr.parent() {
match_ast! {
match parent {
ast::AttrpathValue(_) => {
if let Some(doc_comment) = get_doc_comment(parent) {
doc_comment.doc_text().map(|v| v.to_owned())
}else{
None
}
},
_ => {
// Yet unhandled ast-nodes
None
}

}
}
// None
} else {
// There is no parent;
// No further places where a doc-comment could be.
None
}
}

/// Looks backwards from the given expression
/// Only whitespace or non-doc-comments are allowed in between an expression and the doc-comment.
/// Any other Node or Token stops the peek.
fn get_doc_comment(expr: &SyntaxNode) -> Option<ast::Comment> {
let mut prev = expr.prev_sibling_or_token();
loop {
match prev {
Some(rnix::NodeOrToken::Token(ref token)) => {
match_ast! { match token {
ast::Whitespace(_) => {
prev = token.prev_sibling_or_token();
},
ast::Comment(it) => {
if it.doc_text().is_some() {
break Some(it);
}else{
//Ignore non-doc comments.
prev = token.prev_sibling_or_token();
}
},
_ => {
break None;
}
}}
}
_ => break None,
};
}
}
104 changes: 104 additions & 0 deletions src/format.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use textwrap::dedent;

/// Ensure all lines in a multi-line doc-comments have the same indentation.
///
/// Consider such a doc comment:
///
/// ```nix
/// {
/// /* foo is
/// the value:
/// 10
/// */
/// foo = 10;
/// }
/// ```
///
/// The parser turns this into:
///
/// ```
/// foo is
/// the value:
/// 10
/// ```
///
///
/// where the first line has no leading indentation, and all other lines have preserved their
/// original indentation.
///
/// What we want instead is:
///
/// ```
/// foo is
/// the value:
/// 10
/// ```
///
/// i.e. we want the whole thing to be dedented. To achieve this, we remove all leading whitespace
/// from the first line, and remove all common whitespace from the rest of the string.
pub fn handle_indentation(raw: &str) -> Option<String> {
let result: String = match raw.split_once('\n') {
Some((first, rest)) => {
format!("{}\n{}", first.trim_start(), dedent(rest))
}
None => raw.into(),
};

Some(result.trim().to_owned()).filter(|s| !s.is_empty())
}

/// Shift down markdown headings
///
/// Performs a line-wise matching to ' # Heading '
///
/// Counts the current numbers of '#' and adds levels: [usize] to them
/// levels := 1; gives
/// '# Heading' -> '## Heading'
///
/// Markdown has 6 levels of headings. Everything beyond that (e.g., H7) may produce unexpected renderings.
/// by default this function makes sure, headings don't exceed the H6 boundary.
/// levels := 2;
/// ...
/// H4 -> H6
/// H6 -> H6
///
pub fn shift_headings(raw: &str, levels: usize) -> String {
let mut result = String::new();
for line in raw.lines() {
if line.trim_start().starts_with('#') {
result.push_str(&format!("{}\n", &handle_heading(line, levels)));
} else {
result.push_str(&format!("{line}\n"));
}
}
result
}

// Dumb heading parser.
pub fn handle_heading(line: &str, levels: usize) -> String {
let chars = line.chars();

let mut leading_trivials: String = String::new();
let mut hashes = String::new();
let mut rest = String::new();
for char in chars {
match char {
' ' | '\t' if hashes.is_empty() => {
// only collect trivial before the initial hash
leading_trivials.push(char)
}
'#' if rest.is_empty() => {
// only collect hashes if no other tokens
hashes.push(char)
}
_ => rest.push(char),
}
}
let new_hashes = match hashes.len() + levels {
// We reached the maximum heading size.
6.. => "#".repeat(6),
_ => "#".repeat(hashes.len() + levels),
};

format!("{leading_trivials}{new_hashes} {rest}")
}
Loading

0 comments on commit dcddb1d

Please sign in to comment.