feat: add submodules for plugin ecosystem
This commit is contained in:
parent
b99dcc83c3
commit
e5bcca1013
4
.gitmodules
vendored
4
.gitmodules
vendored
@ -7,3 +7,7 @@
|
||||
[submodule "bin_archives"]
|
||||
path = bin_archives
|
||||
url = https://repo.jesusperez.pro/jesus/nushell-plugins-bin_archives
|
||||
[submodule "nu_plugin_fluent"]
|
||||
path = nu_plugin_fluent
|
||||
url = ssh://git@repo.jesusperez.pro:32225/jesus/nu_plugin_fluent.git
|
||||
branch = main
|
2278
nu_plugin_fluent/Cargo.lock
generated
Normal file
2278
nu_plugin_fluent/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
nu_plugin_fluent/Cargo.toml
Normal file
26
nu_plugin_fluent/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "nu_plugin_fluent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Nushell plugin for Fluent i18n integration"
|
||||
authors = ["Jesús Pérex <jpl@jesusperez.com>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
keywords = ["nushell", "plugin", "i18n", "fluent", "localization"]
|
||||
repository = "https://github.com/JesusPerez/nu_plugin_fluent"
|
||||
categories = ["localization", "command-line-utilities"]
|
||||
|
||||
[dependencies]
|
||||
# for local development, you can use a path dependency
|
||||
nu-plugin = { version = "0.107.1", path = "../nushell/crates/nu-plugin" }
|
||||
nu-protocol = { version = "0.107.1", path = "../nushell/crates/nu-protocol", features = ["plugin"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
fluent = "0.17"
|
||||
fluent-bundle = "0.16"
|
||||
fluent-syntax = "0.12"
|
||||
unic-langid = "0.9"
|
||||
thiserror = "2.0"
|
||||
indexmap = "2.11"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.22"
|
356
nu_plugin_fluent/README.md
Normal file
356
nu_plugin_fluent/README.md
Normal file
@ -0,0 +1,356 @@
|
||||
# nu_plugin_fluent
|
||||
|
||||
A [Nushell](https://nushell.sh/) plugin for [Fluent](https://projectfluent.org/) internationalization (i18n) and localization (l10n) workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin provides powerful tools for managing multilingual applications using Mozilla's Fluent localization system. It enables you to parse, validate, localize, and manage Fluent Translation List (`.ftl`) files directly from Nushell.
|
||||
|
||||
## Installing
|
||||
|
||||
Clone this repository
|
||||
|
||||
> [!WARNING]
|
||||
> **nu_plugin_fluent** has dependencies to nushell source via local path in Cargo.toml
|
||||
> Nushell and plugins require to be **sync** with same **version**
|
||||
|
||||
Clone [Nushell](https://nushell.sh/) alongside this plugin or change dependencies in [Cargo.toml](Cargo.toml)
|
||||
|
||||
This plugin is also included as submodule in [nushell-plugins](https://repo.jesusperez.pro/jesus/nushell-plugins)
|
||||
as part of plugins collection for [Provisioning project](https://rlung.librecloud.online/jesus/provisioning)
|
||||
|
||||
Build from source
|
||||
|
||||
```nushell
|
||||
> cd nu_plugin_fluent
|
||||
> cargo install --path .
|
||||
```
|
||||
|
||||
### Nushell
|
||||
|
||||
In a [Nushell](https://nushell.sh/)
|
||||
|
||||
```nushell
|
||||
> plugin add ~/.cargo/bin/nu_plugin_fluent
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `fluent-parse`
|
||||
|
||||
Parse a Fluent Translation List (`.ftl`) file and extract its message structure.
|
||||
|
||||
```nushell
|
||||
> fluent-parse <file>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- **file** `<path>`: FTL file to parse
|
||||
|
||||
**Example:**
|
||||
```nushell
|
||||
> fluent-parse locales/en-US/main.ftl
|
||||
╭───────────────┬─────────────────────────────╮
|
||||
│ file │ locales/en-US/main.ftl │
|
||||
│ message_count │ 3 │
|
||||
│ messages │ [list of parsed messages] │
|
||||
╰───────────────┴─────────────────────────────╯
|
||||
```
|
||||
|
||||
### `fluent-localize`
|
||||
|
||||
Localize a message using the Fluent translation system with hierarchical fallback support.
|
||||
|
||||
```nushell
|
||||
> fluent-localize <message_id> <locale> [--files] [--bundle] [--args] [--fallback]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- **message_id** `<string>`: Message ID to localize
|
||||
- **locale** `<string>`: Locale code (e.g., en-US, es-ES)
|
||||
|
||||
**Flags:**
|
||||
- **--files** `-f` `<list>`: FTL files to load
|
||||
- **--bundle** `-b` `<record>`: Pre-loaded message bundle
|
||||
- **--args** `-a` `<record>`: Arguments for message interpolation
|
||||
- **--fallback** `-F`: Return message ID if translation not found
|
||||
|
||||
**Examples:**
|
||||
|
||||
Basic localization:
|
||||
```nushell
|
||||
> fluent-localize welcome-message en-US --files [locales/en-US/main.ftl]
|
||||
"Welcome to our application!"
|
||||
```
|
||||
|
||||
With arguments:
|
||||
```nushell
|
||||
> fluent-localize user-greeting en-US --files [locales/en-US/main.ftl] --args {name: "Alice"}
|
||||
"Hello, Alice! Welcome back."
|
||||
```
|
||||
|
||||
With fallback:
|
||||
```nushell
|
||||
> fluent-localize missing-message es-ES --files [locales/es-ES/main.ftl] --fallback
|
||||
"[[missing-message]]"
|
||||
```
|
||||
|
||||
### `fluent-validate`
|
||||
|
||||
Validate the syntax of a Fluent Translation List (`.ftl`) file.
|
||||
|
||||
```nushell
|
||||
> fluent-validate <file>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- **file** `<path>`: FTL file to validate
|
||||
|
||||
**Example:**
|
||||
```nushell
|
||||
> fluent-validate locales/en-US/main.ftl
|
||||
╭────────┬─────────────────────────╮
|
||||
│ valid │ true │
|
||||
│ file │ locales/en-US/main.ftl │
|
||||
│ errors │ [] │
|
||||
╰────────┴─────────────────────────╯
|
||||
```
|
||||
|
||||
### `fluent-extract`
|
||||
|
||||
Extract message IDs from a Fluent Translation List (`.ftl`) file.
|
||||
|
||||
```nushell
|
||||
> fluent-extract <file>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- **file** `<path>`: FTL file to extract messages from
|
||||
|
||||
**Example:**
|
||||
```nushell
|
||||
> fluent-extract locales/en-US/main.ftl
|
||||
╭───┬─────────────────╮
|
||||
│ 0 │ welcome-message │
|
||||
│ 1 │ user-greeting │
|
||||
│ 2 │ goodbye-message │
|
||||
╰───┴─────────────────╯
|
||||
```
|
||||
|
||||
### `fluent-list-locales`
|
||||
|
||||
List available locales from a directory structure.
|
||||
|
||||
```nushell
|
||||
> fluent-list-locales <directory>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- **directory** `<path>`: Directory containing locale folders
|
||||
|
||||
**Example:**
|
||||
```nushell
|
||||
> fluent-list-locales ./locales
|
||||
╭───┬───────╮
|
||||
│ 0 │ en-US │
|
||||
│ 1 │ es-ES │
|
||||
│ 2 │ fr-FR │
|
||||
│ 3 │ de-DE │
|
||||
╰───┴───────╯
|
||||
```
|
||||
|
||||
### `fluent-create-bundle`
|
||||
|
||||
Create a merged Fluent bundle from global and page-specific locale files.
|
||||
|
||||
```nushell
|
||||
> fluent-create-bundle <locale> [--global] [--page] [--fallback] [--override]
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- **locale** `<string>`: Locale code (e.g., en-US)
|
||||
|
||||
**Flags:**
|
||||
- **--global** `-g` `<list>`: Global FTL files to include
|
||||
- **--page** `-p` `<list>`: Page-specific FTL files to include
|
||||
- **--fallback** `-f` `<list>`: Fallback locales in order
|
||||
- **--override** `-o`: Allow page files to override global messages
|
||||
|
||||
**Examples:**
|
||||
|
||||
Create bundle with global and page-specific files:
|
||||
```nushell
|
||||
> fluent-create-bundle en-US --global [global/en-US/common.ftl] --page [pages/blog/en-US/blog.ftl]
|
||||
```
|
||||
|
||||
Create bundle with fallback support:
|
||||
```nushell
|
||||
> fluent-create-bundle es-ES --fallback [en-US] --global [global/es-ES/common.ftl]
|
||||
```
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Complete Localization Workflow
|
||||
|
||||
1. **Set up your locale directory structure:**
|
||||
```
|
||||
locales/
|
||||
├── en-US/
|
||||
│ ├── common.ftl
|
||||
│ └── pages.ftl
|
||||
├── es-ES/
|
||||
│ ├── common.ftl
|
||||
│ └── pages.ftl
|
||||
└── fr-FR/
|
||||
├── common.ftl
|
||||
└── pages.ftl
|
||||
```
|
||||
|
||||
2. **List available locales:**
|
||||
```nushell
|
||||
> fluent-list-locales ./locales
|
||||
```
|
||||
|
||||
3. **Validate all locale files:**
|
||||
```nushell
|
||||
> ls locales/**/*.ftl | each { |file| fluent-validate $file.name }
|
||||
```
|
||||
|
||||
4. **Extract all message IDs for translation coverage:**
|
||||
```nushell
|
||||
> ls locales/en-US/*.ftl | each { |file|
|
||||
fluent-extract $file.name | wrap messages | insert file $file.name
|
||||
} | flatten
|
||||
```
|
||||
|
||||
5. **Create a localization function:**
|
||||
```nushell
|
||||
def localize [message_id: string, locale: string = "en-US"] {
|
||||
let files = (ls $"locales/($locale)/*.ftl" | get name)
|
||||
fluent-localize $message_id $locale --files $files --fallback
|
||||
}
|
||||
```
|
||||
|
||||
6. **Use the localization function:**
|
||||
```nushell
|
||||
> localize "welcome-message" "es-ES"
|
||||
"¡Bienvenido a nuestra aplicación!"
|
||||
```
|
||||
|
||||
### Quality Assurance Workflow
|
||||
|
||||
Check for missing translations across locales:
|
||||
|
||||
```nushell
|
||||
def check-translation-coverage [] {
|
||||
let base_locale = "en-US"
|
||||
let base_messages = (ls $"locales/($base_locale)/*.ftl"
|
||||
| each { |file| fluent-extract $file.name }
|
||||
| flatten | uniq)
|
||||
|
||||
ls locales/*/
|
||||
| get name
|
||||
| path basename
|
||||
| where $it != $base_locale
|
||||
| each { |locale|
|
||||
let locale_messages = (ls $"locales/($locale)/*.ftl"
|
||||
| each { |file| fluent-extract $file.name }
|
||||
| flatten | uniq)
|
||||
|
||||
let missing = ($base_messages | where $it not-in $locale_messages)
|
||||
{locale: $locale, missing_count: ($missing | length), missing: $missing}
|
||||
}
|
||||
}
|
||||
|
||||
> check-translation-coverage
|
||||
```
|
||||
|
||||
## Fluent File Format
|
||||
|
||||
Example `.ftl` file structure:
|
||||
|
||||
**locales/en-US/common.ftl**
|
||||
```fluent
|
||||
# Simple message
|
||||
welcome-message = Welcome to our application!
|
||||
|
||||
# Message with variables
|
||||
user-greeting = Hello, { $name }! Welcome back.
|
||||
|
||||
# Message with attributes
|
||||
login-button =
|
||||
.label = Sign In
|
||||
.aria-label = Sign in to your account
|
||||
.tooltip = Click here to access your account
|
||||
|
||||
# Message with variants
|
||||
unread-emails = You have { $count ->
|
||||
[one] one unread email
|
||||
*[other] { $count } unread emails
|
||||
}.
|
||||
|
||||
# Message with functions
|
||||
last-login = Last login: { DATETIME($date, month: "long", day: "numeric") }
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Parse FTL files** - Extract and analyze message structure
|
||||
- ✅ **Validate syntax** - Ensure FTL files are syntactically correct
|
||||
- ✅ **Localize messages** - Translate messages with variable interpolation
|
||||
- ✅ **Fallback support** - Hierarchical locale fallback system
|
||||
- ✅ **Bundle management** - Merge global and page-specific translations
|
||||
- ✅ **Message extraction** - List all message IDs for coverage analysis
|
||||
- ✅ **Locale discovery** - Auto-detect available locales
|
||||
- ✅ **Quality assurance** - Tools for translation completeness checking
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Web Applications**: Manage frontend translations with dynamic content
|
||||
- **CLI Tools**: Internationalize command-line applications
|
||||
- **Documentation**: Maintain multilingual documentation systems
|
||||
- **Content Management**: Handle localized content workflows
|
||||
- **Quality Assurance**: Automate translation coverage and validation
|
||||
- **Build Systems**: Integrate i18n validation into CI/CD pipelines
|
||||
|
||||
## Integration with Nushell
|
||||
|
||||
This plugin leverages Nushell's powerful data processing capabilities:
|
||||
|
||||
```nushell
|
||||
# Batch validate all locale files
|
||||
ls locales/**/*.ftl
|
||||
| par-each { |file| fluent-validate $file.name }
|
||||
| where valid == false
|
||||
|
||||
# Generate translation progress report
|
||||
def translation-report [] {
|
||||
fluent-list-locales ./locales
|
||||
| each { |locale|
|
||||
let files = (ls $"locales/($locale)/*.ftl" | get name)
|
||||
let message_count = ($files | each { |f| fluent-extract $f } | flatten | length)
|
||||
{locale: $locale, messages: $message_count}
|
||||
}
|
||||
}
|
||||
|
||||
# Find untranslated messages
|
||||
def find-missing-translations [base_locale: string, target_locale: string] {
|
||||
let base_msgs = (ls $"locales/($base_locale)/*.ftl" | each { |f| fluent-extract $f.name } | flatten)
|
||||
let target_msgs = (ls $"locales/($target_locale)/*.ftl" | each { |f| fluent-extract $f.name } | flatten)
|
||||
$base_msgs | where $it not-in $target_msgs
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [Fluent](https://projectfluent.org/) - Mozilla's localization system
|
||||
- [Nushell](https://nushell.sh/) - A new type of shell
|
||||
- [nu_plugin_tera](../nu_plugin_tera/) - Tera templating plugin for Nushell
|
203
nu_plugin_fluent/src/commands/create_bundle.rs
Normal file
203
nu_plugin_fluent/src/commands/create_bundle.rs
Normal file
@ -0,0 +1,203 @@
|
||||
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||
use nu_protocol::{
|
||||
Category, LabeledError, Signature, Span, SyntaxShape, Type, Value, record,
|
||||
};
|
||||
use fluent::{FluentBundle, FluentResource};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
use crate::FluentPlugin;
|
||||
|
||||
pub struct CreateBundle;
|
||||
|
||||
impl SimplePluginCommand for CreateBundle {
|
||||
type Plugin = FluentPlugin;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"fluent-create-bundle"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::Record(vec![].into()))
|
||||
.required("locale", SyntaxShape::String, "Locale code (e.g., en-US)")
|
||||
.named(
|
||||
"global",
|
||||
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
|
||||
"Global FTL files to include",
|
||||
Some('g'),
|
||||
)
|
||||
.named(
|
||||
"page",
|
||||
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
|
||||
"Page-specific FTL files to include",
|
||||
Some('p'),
|
||||
)
|
||||
.named(
|
||||
"fallback",
|
||||
SyntaxShape::List(Box::new(SyntaxShape::String)),
|
||||
"Fallback locales in order",
|
||||
Some('f'),
|
||||
)
|
||||
.switch("override", "Allow page files to override global messages", Some('o'))
|
||||
.category(Category::Strings)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Create a merged Fluent bundle from global and page-specific locale files"
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||
vec![
|
||||
nu_protocol::Example {
|
||||
description: "Create bundle with global and page-specific files",
|
||||
example: "fluent-create-bundle en-US --global [global/en-US/common.ftl] --page [pages/blog/en-US/blog.ftl]",
|
||||
result: None,
|
||||
},
|
||||
nu_protocol::Example {
|
||||
description: "Create bundle with fallback support",
|
||||
example: "fluent-create-bundle es-ES --fallback [en-US] --global [global/es-ES/common.ftl]",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &Self::Plugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
_input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let locale_code: String = call.req(0)?;
|
||||
|
||||
// Parse locale
|
||||
let locale: LanguageIdentifier = locale_code.parse()
|
||||
.map_err(|e| LabeledError::new("Invalid locale").with_label(format!("Invalid locale '{}': {}", locale_code, e), call.head))?;
|
||||
|
||||
// Create fallback locales
|
||||
let mut locales = vec![locale.clone()];
|
||||
if let Some(fallback_value) = call.get_flag("fallback")? {
|
||||
let fallback_codes = extract_string_list(fallback_value)?;
|
||||
for code in fallback_codes {
|
||||
let fallback_locale: LanguageIdentifier = code.parse()
|
||||
.map_err(|e| LabeledError::new("Invalid fallback locale").with_label(format!("Invalid fallback locale '{}': {}", code, e), call.head))?;
|
||||
locales.push(fallback_locale);
|
||||
}
|
||||
}
|
||||
|
||||
// Create bundle
|
||||
let mut bundle = FluentBundle::new(locales);
|
||||
let allow_override = call.has_flag("override")?;
|
||||
|
||||
// Load global files first (lower priority)
|
||||
if let Some(global_value) = call.get_flag("global")? {
|
||||
let global_files = extract_file_list(global_value)?;
|
||||
load_files_to_bundle(&mut bundle, &global_files, "global")?;
|
||||
}
|
||||
|
||||
// Load page files (higher priority, can override global)
|
||||
if let Some(page_value) = call.get_flag("page")? {
|
||||
let page_files = extract_file_list(page_value)?;
|
||||
if allow_override {
|
||||
// Page files can override global messages
|
||||
load_files_to_bundle(&mut bundle, &page_files, "page")?;
|
||||
} else {
|
||||
// Only add page messages that don't exist in global
|
||||
load_files_to_bundle_no_override(&mut bundle, &page_files)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract bundle information
|
||||
let bundle_info = extract_bundle_info(&bundle, &locale_code, call.head);
|
||||
|
||||
Ok(bundle_info)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_string_list(value: Value) -> Result<Vec<String>, LabeledError> {
|
||||
match value {
|
||||
Value::List { vals, .. } => {
|
||||
vals.iter()
|
||||
.map(|v| value_to_string(v))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
_ => Err(LabeledError::new("Invalid list").with_label("Must be a list of strings", nu_protocol::Span::unknown())),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_file_list(value: Value) -> Result<Vec<String>, LabeledError> {
|
||||
extract_string_list(value)
|
||||
}
|
||||
|
||||
fn load_files_to_bundle(
|
||||
bundle: &mut FluentBundle<FluentResource>,
|
||||
files: &[String],
|
||||
source: &str,
|
||||
) -> Result<(), LabeledError> {
|
||||
for file_path in files {
|
||||
let content = std::fs::read_to_string(file_path)
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read '{}': {}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||
|
||||
let resource = FluentResource::try_new(content)
|
||||
.map_err(|e| LabeledError::new("Invalid FTL").with_label(format!("Invalid FTL in '{}': {:?}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||
|
||||
bundle.add_resource(resource)
|
||||
.map_err(|errors| {
|
||||
let error_msgs: Vec<String> = errors.iter()
|
||||
.map(|e| format!("{:?}", e))
|
||||
.collect();
|
||||
LabeledError::new("Load error").with_label(format!(
|
||||
"Failed to load {} file '{}': {}",
|
||||
source,
|
||||
file_path,
|
||||
error_msgs.join(", ")
|
||||
), nu_protocol::Span::unknown())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_files_to_bundle_no_override(
|
||||
bundle: &mut FluentBundle<FluentResource>,
|
||||
files: &[String],
|
||||
) -> Result<(), LabeledError> {
|
||||
// This is a simplified implementation - in practice, we'd need to
|
||||
// check for conflicts before adding resources
|
||||
load_files_to_bundle(bundle, files, "page")
|
||||
}
|
||||
|
||||
fn extract_bundle_info(
|
||||
_bundle: &FluentBundle<FluentResource>,
|
||||
locale_code: &str,
|
||||
span: Span,
|
||||
) -> Value {
|
||||
// Extract message IDs and basic info from the bundle
|
||||
let message_ids = Vec::new();
|
||||
let message_count = 0;
|
||||
|
||||
// This is a simplified extraction - the actual FluentBundle API
|
||||
// doesn't directly expose message enumeration, so in practice
|
||||
// we'd need to track this during bundle creation
|
||||
|
||||
Value::record(
|
||||
record! {
|
||||
"locale" => Value::string(locale_code.to_string(), span),
|
||||
"message_count" => Value::int(message_count, span),
|
||||
"message_ids" => Value::list(message_ids, span),
|
||||
"bundle_type" => Value::string("merged".to_string(), span),
|
||||
},
|
||||
span,
|
||||
)
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> Result<String, LabeledError> {
|
||||
match value {
|
||||
Value::String { val, .. } => Ok(val.clone()),
|
||||
Value::Int { val, .. } => Ok(val.to_string()),
|
||||
Value::Float { val, .. } => Ok(val.to_string()),
|
||||
Value::Bool { val, .. } => Ok(val.to_string()),
|
||||
_ => Err(LabeledError::new("Type conversion error").with_label(
|
||||
format!("Cannot convert {:?} to string", value.get_type()),
|
||||
nu_protocol::Span::unknown()
|
||||
)),
|
||||
}
|
||||
}
|
68
nu_plugin_fluent/src/commands/extract_messages.rs
Normal file
68
nu_plugin_fluent/src/commands/extract_messages.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||
use nu_protocol::{Category, LabeledError, Signature, SyntaxShape, Type, Value};
|
||||
use crate::FluentPlugin;
|
||||
|
||||
pub struct ExtractMessages;
|
||||
|
||||
impl SimplePluginCommand for ExtractMessages {
|
||||
type Plugin = FluentPlugin;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"fluent-extract"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::List(Box::new(Type::String)))
|
||||
.required("file", SyntaxShape::Filepath, "FTL file to extract messages from")
|
||||
.category(Category::Strings)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Extract message IDs from a Fluent Translation List (.ftl) file"
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||
vec![
|
||||
nu_protocol::Example {
|
||||
description: "Extract message IDs from an FTL file",
|
||||
example: "fluent-extract locales/en-US/main.ftl",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &Self::Plugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let _file_path: String = call.req(0)?;
|
||||
|
||||
let parsed = SimplePluginCommand::run(&ParseFtl, _plugin, _engine, call, input)?;
|
||||
|
||||
// Extract message IDs from the parsed result
|
||||
if let Value::Record { val, .. } = parsed {
|
||||
if let Some(Value::List { vals, .. }) = val.get("messages") {
|
||||
let message_ids: Vec<Value> = vals
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
if let Value::Record { val, .. } = msg {
|
||||
val.get("id").cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(Value::list(message_ids, call.head));
|
||||
}
|
||||
}
|
||||
|
||||
Err(LabeledError::new("Extract error").with_label("Failed to extract messages", call.head))
|
||||
}
|
||||
}
|
||||
|
||||
use crate::commands::ParseFtl;
|
69
nu_plugin_fluent/src/commands/list_locales.rs
Normal file
69
nu_plugin_fluent/src/commands/list_locales.rs
Normal file
@ -0,0 +1,69 @@
|
||||
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||
use nu_protocol::{Category, LabeledError, Signature, SyntaxShape, Type, Value};
|
||||
use std::fs;
|
||||
use crate::FluentPlugin;
|
||||
|
||||
pub struct ListLocales;
|
||||
|
||||
impl SimplePluginCommand for ListLocales {
|
||||
type Plugin = FluentPlugin;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"fluent-list-locales"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::List(Box::new(Type::String)))
|
||||
.required("directory", SyntaxShape::Directory, "Directory containing locale folders")
|
||||
.category(Category::Strings)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List available locales from a directory structure"
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||
vec![
|
||||
nu_protocol::Example {
|
||||
description: "List available locales",
|
||||
example: "fluent-list-locales ./locales",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &Self::Plugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
_input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let directory: String = call.req(0)?;
|
||||
|
||||
let entries = fs::read_dir(&directory)
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read directory '{}': {}", directory, e), call.head))?;
|
||||
|
||||
let mut locales = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read directory entry: {}", e), call.head))?;
|
||||
|
||||
if entry.file_type()
|
||||
.map_err(|e| LabeledError::new("File type error").with_label(format!("Failed to get file type: {}", e), call.head))?
|
||||
.is_dir()
|
||||
{
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
// Basic locale pattern matching (e.g., en-US, es-ES)
|
||||
if name.len() == 5 && name.chars().nth(2) == Some('-') {
|
||||
locales.push(Value::string(name.to_string(), call.head));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Value::list(locales, call.head))
|
||||
}
|
||||
}
|
253
nu_plugin_fluent/src/commands/localize.rs
Normal file
253
nu_plugin_fluent/src/commands/localize.rs
Normal file
@ -0,0 +1,253 @@
|
||||
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||
use nu_protocol::{
|
||||
Category, LabeledError, Signature, SyntaxShape, Type, Value
|
||||
};
|
||||
use fluent::{FluentBundle, FluentResource, FluentArgs, FluentValue};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
use crate::FluentPlugin;
|
||||
|
||||
pub struct Localize;
|
||||
|
||||
impl SimplePluginCommand for Localize {
|
||||
type Plugin = FluentPlugin;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"fluent-localize"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::String)
|
||||
.required("message_id", SyntaxShape::String, "Message ID to localize")
|
||||
.required("locale", SyntaxShape::String, "Locale code (e.g., en-US)")
|
||||
.named(
|
||||
"bundle",
|
||||
SyntaxShape::Record(vec![]),
|
||||
"Pre-loaded message bundle",
|
||||
Some('b'),
|
||||
)
|
||||
.named(
|
||||
"args",
|
||||
SyntaxShape::Record(vec![]),
|
||||
"Arguments for message interpolation",
|
||||
Some('a'),
|
||||
)
|
||||
.named(
|
||||
"files",
|
||||
SyntaxShape::List(Box::new(SyntaxShape::Filepath)),
|
||||
"FTL files to load",
|
||||
Some('f'),
|
||||
)
|
||||
.switch("fallback", "Return message ID if translation not found", Some('F'))
|
||||
.category(Category::Strings)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Localize a message using Fluent translation system with hierarchical fallback"
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||
vec![
|
||||
nu_protocol::Example {
|
||||
description: "Localize a simple message",
|
||||
example: "fluent-localize welcome-message en-US",
|
||||
result: None,
|
||||
},
|
||||
nu_protocol::Example {
|
||||
description: "Localize with arguments",
|
||||
example: "fluent-localize user-greeting en-US --args {name: 'Alice'}",
|
||||
result: None,
|
||||
},
|
||||
nu_protocol::Example {
|
||||
description: "Use pre-loaded bundle",
|
||||
example: "let bundle = (fluent-parse messages.ftl); fluent-localize welcome-message en-US --bundle $bundle",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &Self::Plugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
_input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let message_id: String = call.req(0)?;
|
||||
let locale_code: String = call.req(1)?;
|
||||
|
||||
// Parse locale
|
||||
let locale: LanguageIdentifier = locale_code.parse()
|
||||
.map_err(|e| LabeledError::new("Invalid locale").with_label(format!("Invalid locale '{}': {}", locale_code, e), call.head))?;
|
||||
|
||||
// Create FluentBundle
|
||||
let mut bundle = FluentBundle::new(vec![locale.clone()]);
|
||||
|
||||
// Load messages from various sources
|
||||
if let Some(bundle_value) = call.get_flag("bundle")? {
|
||||
// Use pre-loaded bundle (from cache or previous parse)
|
||||
load_from_bundle_value(&mut bundle, bundle_value)?;
|
||||
} else if let Some(files_value) = call.get_flag("files")? {
|
||||
// Load from FTL files
|
||||
let files = extract_file_list(files_value)?;
|
||||
load_from_files(&mut bundle, &files)?;
|
||||
} else {
|
||||
return Err(LabeledError::new("Missing argument").with_label("Must provide either --bundle or --files", call.head));
|
||||
}
|
||||
|
||||
// Prepare arguments for interpolation
|
||||
let fluent_args = if let Some(args_value) = call.get_flag("args")? {
|
||||
Some(convert_to_fluent_args(args_value)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the message
|
||||
let msg = match bundle.get_message(&message_id) {
|
||||
Some(msg) => msg,
|
||||
None => {
|
||||
if call.has_flag("fallback").unwrap_or(false) {
|
||||
// Return message ID as fallback
|
||||
return Ok(Value::string(format!("[[{}]]", message_id), call.head));
|
||||
} else {
|
||||
return Err(LabeledError::new("Message not found").with_label(format!("Message '{}' not found in locale '{}'", message_id, locale_code), call.head));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Format the message
|
||||
let pattern = msg.value()
|
||||
.ok_or_else(|| LabeledError::new("Message has no value").with_label(format!("Message '{}' has no value", message_id), call.head))?;
|
||||
|
||||
let mut errors = vec![];
|
||||
let formatted = bundle.format_pattern(
|
||||
pattern,
|
||||
fluent_args.as_ref(),
|
||||
&mut errors
|
||||
);
|
||||
|
||||
// Handle formatting errors
|
||||
if !errors.is_empty() {
|
||||
let error_msgs: Vec<String> = errors.iter()
|
||||
.map(|e| format!("{:?}", e))
|
||||
.collect();
|
||||
return Err(LabeledError::new("Formatting error").with_label(format!(
|
||||
"Formatting errors for message '{}': {}",
|
||||
message_id,
|
||||
error_msgs.join(", ")
|
||||
), call.head));
|
||||
}
|
||||
|
||||
Ok(Value::string(formatted.to_string(), call.head))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_bundle_value(
|
||||
bundle: &mut FluentBundle<FluentResource>,
|
||||
bundle_value: Value,
|
||||
) -> Result<(), LabeledError> {
|
||||
match bundle_value {
|
||||
Value::Record { val, .. } => {
|
||||
// Extract messages from parsed bundle record
|
||||
if let Some(messages_value) = val.get("messages") {
|
||||
load_messages_from_value(bundle, messages_value)?;
|
||||
}
|
||||
}
|
||||
_ => return Err(LabeledError::new("Invalid bundle").with_label("Bundle must be a record", nu_protocol::Span::unknown())),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_messages_from_value(
|
||||
bundle: &mut FluentBundle<FluentResource>,
|
||||
messages_value: &Value,
|
||||
) -> Result<(), LabeledError> {
|
||||
match messages_value {
|
||||
Value::List { vals, .. } => {
|
||||
for message_val in vals {
|
||||
if let Value::Record { val, .. } = message_val {
|
||||
if let (Some(id_val), Some(text_val)) = (val.get("id"), val.get("text")) {
|
||||
let id = value_to_string(id_val)?;
|
||||
let text = value_to_string(text_val)?;
|
||||
|
||||
// Create a minimal FTL resource from the message
|
||||
let ftl_content = format!("{} = {}", id, text);
|
||||
let resource = FluentResource::try_new(ftl_content)
|
||||
.map_err(|_| LabeledError::new("Invalid FTL").with_label(format!("Invalid FTL for message '{}'", id), nu_protocol::Span::unknown()))?;
|
||||
|
||||
bundle.add_resource(resource)
|
||||
.map_err(|_| LabeledError::new("Failed to add message").with_label(format!("Failed to add message '{}'", id), nu_protocol::Span::unknown()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(LabeledError::new("Invalid messages").with_label("Messages must be a list", nu_protocol::Span::unknown())),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_from_files(
|
||||
bundle: &mut FluentBundle<FluentResource>,
|
||||
files: &[String],
|
||||
) -> Result<(), LabeledError> {
|
||||
for file_path in files {
|
||||
let content = std::fs::read_to_string(file_path)
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read '{}': {}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||
|
||||
let resource = FluentResource::try_new(content)
|
||||
.map_err(|e| LabeledError::new("Invalid FTL").with_label(format!("Invalid FTL in '{}': {:?}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||
|
||||
bundle.add_resource(resource)
|
||||
.map_err(|e| LabeledError::new("Load error").with_label(format!("Failed to load '{}': {:?}", file_path, e), nu_protocol::Span::unknown()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_file_list(files_value: Value) -> Result<Vec<String>, LabeledError> {
|
||||
match files_value {
|
||||
Value::List { vals, .. } => {
|
||||
vals.iter()
|
||||
.map(|v| value_to_string(v))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
_ => Err(LabeledError::new("Invalid files").with_label("Files must be a list of strings", nu_protocol::Span::unknown())),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_to_fluent_args(args_value: Value) -> Result<FluentArgs<'static>, LabeledError> {
|
||||
let mut fluent_args = FluentArgs::new();
|
||||
|
||||
match args_value {
|
||||
Value::Record { val, .. } => {
|
||||
for (key, value) in val.iter() {
|
||||
let fluent_value = match value {
|
||||
Value::String { val, .. } => FluentValue::from(val.clone()),
|
||||
Value::Int { val, .. } => FluentValue::from(*val as f64),
|
||||
Value::Float { val, .. } => FluentValue::from(*val),
|
||||
_ => return Err(LabeledError::new("Unsupported argument type").with_label(format!(
|
||||
"Unsupported argument type for '{}': {:?}",
|
||||
key,
|
||||
value.get_type()
|
||||
), nu_protocol::Span::unknown())),
|
||||
};
|
||||
fluent_args.set(key.clone(), fluent_value);
|
||||
}
|
||||
}
|
||||
_ => return Err(LabeledError::new("Invalid arguments").with_label("Arguments must be a record", nu_protocol::Span::unknown())),
|
||||
}
|
||||
|
||||
Ok(fluent_args)
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> Result<String, LabeledError> {
|
||||
match value {
|
||||
Value::String { val, .. } => Ok(val.clone()),
|
||||
Value::Int { val, .. } => Ok(val.to_string()),
|
||||
Value::Float { val, .. } => Ok(val.to_string()),
|
||||
Value::Bool { val, .. } => Ok(val.to_string()),
|
||||
_ => Err(LabeledError::new("Type conversion error").with_label(
|
||||
format!("Cannot convert {:?} to string", value.get_type()),
|
||||
nu_protocol::Span::unknown()
|
||||
)),
|
||||
}
|
||||
}
|
14
nu_plugin_fluent/src/commands/mod.rs
Normal file
14
nu_plugin_fluent/src/commands/mod.rs
Normal file
@ -0,0 +1,14 @@
|
||||
mod parse_ftl;
|
||||
mod localize;
|
||||
mod validate_ftl;
|
||||
mod extract_messages;
|
||||
mod list_locales;
|
||||
mod create_bundle;
|
||||
|
||||
pub use parse_ftl::ParseFtl;
|
||||
pub use localize::Localize;
|
||||
pub use validate_ftl::ValidateFtl;
|
||||
pub use extract_messages::ExtractMessages;
|
||||
pub use list_locales::ListLocales;
|
||||
pub use create_bundle::CreateBundle;
|
||||
|
143
nu_plugin_fluent/src/commands/parse_ftl.rs
Normal file
143
nu_plugin_fluent/src/commands/parse_ftl.rs
Normal file
@ -0,0 +1,143 @@
|
||||
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||
use nu_protocol::{
|
||||
Category, LabeledError, Signature, Span, SyntaxShape, Type, Value, record
|
||||
};
|
||||
use fluent_syntax::parser::parse;
|
||||
use fluent_syntax::ast::{Entry, Message, Pattern, PatternElement};
|
||||
use std::fs;
|
||||
use crate::FluentPlugin;
|
||||
|
||||
pub struct ParseFtl;
|
||||
|
||||
impl SimplePluginCommand for ParseFtl {
|
||||
type Plugin = FluentPlugin;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"fluent-parse"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::Record(vec![].into()))
|
||||
.required("file", SyntaxShape::Filepath, "FTL file to parse")
|
||||
.category(Category::Strings)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Parse a Fluent Translation List (.ftl) file and return message structure"
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||
vec![
|
||||
nu_protocol::Example {
|
||||
description: "Parse an FTL file",
|
||||
example: "fluent-parse locales/en-US/main.ftl",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &Self::Plugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
_input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let file_path: String = call.req(0)?;
|
||||
|
||||
// Read FTL file
|
||||
let ftl_content = fs::read_to_string(&file_path)
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read file '{}': {}", file_path, e), call.head))?;
|
||||
|
||||
// Parse FTL content
|
||||
let resource = match parse(ftl_content) {
|
||||
Ok(res) => res,
|
||||
Err((_, errors)) => {
|
||||
return Err(LabeledError::new("Parse error").with_label(format!("Failed to parse FTL content: {:?}", errors), call.head));
|
||||
}
|
||||
};
|
||||
|
||||
// Convert AST to Nushell values
|
||||
let messages = extract_messages_from_resource(&resource, call.head)
|
||||
.map_err(|e| LabeledError::new("Extract error").with_label(e, call.head))?;
|
||||
|
||||
let message_count = messages.len() as i64;
|
||||
let result = Value::record(
|
||||
record! {
|
||||
"file" => Value::string(file_path, call.head),
|
||||
"messages" => Value::list(messages, call.head),
|
||||
"message_count" => Value::int(message_count, call.head),
|
||||
},
|
||||
call.head,
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_messages_from_resource(resource: &fluent_syntax::ast::Resource<String>, span: Span) -> Result<Vec<Value>, String> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for entry in &resource.body {
|
||||
if let Entry::Message(message) = entry {
|
||||
let message_value = extract_message_info(message, span)?;
|
||||
messages.push(message_value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn extract_message_info(message: &Message<String>, span: Span) -> Result<Value, String> {
|
||||
let id = message.id.name.to_string();
|
||||
|
||||
// Extract the default text from the pattern
|
||||
let text = if let Some(ref pattern) = message.value {
|
||||
extract_pattern_text(pattern)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Extract attributes
|
||||
let mut attributes = Vec::new();
|
||||
for attribute in &message.attributes {
|
||||
let attr_value = Value::record(
|
||||
record! {
|
||||
"name" => Value::string(attribute.id.name.to_string(), span),
|
||||
"value" => Value::string(extract_pattern_text(&attribute.value), span),
|
||||
},
|
||||
span,
|
||||
);
|
||||
attributes.push(attr_value);
|
||||
}
|
||||
|
||||
// Extract comment if present
|
||||
let comment = message.comment.as_ref()
|
||||
.map(|c| c.content.iter().map(|line| line.trim()).collect::<Vec<_>>().join("\n"))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Value::record(
|
||||
record! {
|
||||
"id" => Value::string(id, span),
|
||||
"text" => Value::string(text, span),
|
||||
"attributes" => Value::list(attributes, span),
|
||||
"comment" => Value::string(comment, span),
|
||||
},
|
||||
span,
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_pattern_text(pattern: &Pattern<String>) -> String {
|
||||
pattern.elements
|
||||
.iter()
|
||||
.map(|element| match element {
|
||||
PatternElement::TextElement { value } => value.to_string(),
|
||||
PatternElement::Placeable { ref expression } => {
|
||||
// For now, just return placeholder representation
|
||||
format!("{{{}}}", format!("{:?}", expression).replace("\"", ""))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
76
nu_plugin_fluent/src/commands/validate_ftl.rs
Normal file
76
nu_plugin_fluent/src/commands/validate_ftl.rs
Normal file
@ -0,0 +1,76 @@
|
||||
use nu_plugin::{EngineInterface, EvaluatedCall, PluginCommand, SimplePluginCommand};
|
||||
use nu_protocol::{Category, LabeledError, Signature, SyntaxShape, Type, Value, record};
|
||||
use fluent_syntax::parser::parse;
|
||||
use crate::FluentPlugin;
|
||||
|
||||
pub struct ValidateFtl;
|
||||
|
||||
impl SimplePluginCommand for ValidateFtl {
|
||||
type Plugin = FluentPlugin;
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"fluent-validate"
|
||||
}
|
||||
|
||||
fn signature(&self) -> Signature {
|
||||
Signature::build(PluginCommand::name(self))
|
||||
.input_output_type(Type::Any, Type::Record(vec![].into()))
|
||||
.required("file", SyntaxShape::Filepath, "FTL file to validate")
|
||||
.category(Category::Strings)
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Validate a Fluent Translation List (.ftl) file syntax"
|
||||
}
|
||||
|
||||
fn examples(&self) -> Vec<nu_protocol::Example<'_>> {
|
||||
vec![
|
||||
nu_protocol::Example {
|
||||
description: "Validate an FTL file",
|
||||
example: "fluent-validate locales/en-US/main.ftl",
|
||||
result: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
_plugin: &Self::Plugin,
|
||||
_engine: &EngineInterface,
|
||||
call: &EvaluatedCall,
|
||||
_input: &Value,
|
||||
) -> Result<Value, LabeledError> {
|
||||
let file_path: String = call.req(0)?;
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| LabeledError::new("Read error").with_label(format!("Failed to read '{}': {}", file_path, e), call.head))?;
|
||||
|
||||
let parse_result = parse(content);
|
||||
let result = match parse_result {
|
||||
Ok(_resource) => Value::record(
|
||||
record! {
|
||||
"valid" => Value::bool(true, call.head),
|
||||
"file" => Value::string(file_path, call.head),
|
||||
"errors" => Value::list(vec![], call.head),
|
||||
},
|
||||
call.head,
|
||||
),
|
||||
Err((_resource, errors)) => {
|
||||
let error_list: Vec<Value> = errors.iter()
|
||||
.map(|err| Value::string(format!("{:?}", err), call.head))
|
||||
.collect();
|
||||
|
||||
Value::record(
|
||||
record! {
|
||||
"valid" => Value::bool(false, call.head),
|
||||
"file" => Value::string(file_path, call.head),
|
||||
"errors" => Value::list(error_list, call.head),
|
||||
},
|
||||
call.head,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
25
nu_plugin_fluent/src/fluent_plugin.rs
Normal file
25
nu_plugin_fluent/src/fluent_plugin.rs
Normal file
@ -0,0 +1,25 @@
|
||||
use nu_plugin::{Plugin, PluginCommand};
|
||||
|
||||
use crate::commands::{
|
||||
CreateBundle, ExtractMessages, ListLocales, Localize, ParseFtl, ValidateFtl,
|
||||
};
|
||||
|
||||
pub struct FluentPlugin;
|
||||
|
||||
impl Plugin for FluentPlugin {
|
||||
fn version(&self) -> String {
|
||||
env!("CARGO_PKG_VERSION").into()
|
||||
}
|
||||
|
||||
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
|
||||
vec![
|
||||
Box::new(ParseFtl),
|
||||
Box::new(Localize),
|
||||
Box::new(ValidateFtl),
|
||||
Box::new(ExtractMessages),
|
||||
Box::new(ListLocales),
|
||||
Box::new(CreateBundle),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
9
nu_plugin_fluent/src/main.rs
Normal file
9
nu_plugin_fluent/src/main.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use nu_plugin::{serve_plugin, MsgPackSerializer};
|
||||
|
||||
mod commands;
|
||||
mod fluent_plugin;
|
||||
use fluent_plugin::FluentPlugin;
|
||||
|
||||
fn main() {
|
||||
serve_plugin(&FluentPlugin, MsgPackSerializer {});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user